@nextclaw/ui 0.12.3 → 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 (123) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/assets/{ChannelsList-DZWam3Ob.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-C7-1sXqo.js → DocBrowser-QUZ3nfmH.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-CpiIfhJO.js} +1 -1
  7. package/dist/assets/{LogoBadge-DDS1sU_U.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-COnjm8_x.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-Cefg1LFJ.js → SecretsConfig-Bew4EF2A.js} +2 -2
  18. package/dist/assets/{SessionsConfig-BZnmVTIu.js → SessionsConfig-2r2yAGZg.js} +2 -2
  19. package/dist/assets/{book-open-DvWqOode.js → book-open-CJG8Yz3U.js} +1 -1
  20. package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-DkAC5OMC.js} +1 -1
  21. package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
  22. package/dist/assets/{config-BeGwf2Ao.js → config-zvnxSXSP.js} +1 -1
  23. package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-_FMJqZw2.js} +1 -1
  24. package/dist/assets/{dist-B6VMuIQN.js → dist-B1fpOuON.js} +1 -1
  25. package/dist/assets/{dist-RWNFhxvR.js → dist-BCXX7FD-.js} +2 -2
  26. package/dist/assets/{external-link-U86Acd1t.js → external-link-b7gAJWYY.js} +1 -1
  27. package/dist/assets/{hash-D-OVfV3Z.js → hash-Bhy4TwfZ.js} +1 -1
  28. package/dist/assets/i18n-DJg9BPYk.js +1 -0
  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-U1_qDA3U.js → logos-GMeYU9vc.js} +1 -1
  34. package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-C8UbWuMt.js} +1 -1
  35. package/dist/assets/plus-CR7RfK3H.js +1 -0
  36. package/dist/assets/{popover-xWbqMnIN.js → popover-8HSx9wQj.js} +1 -1
  37. package/dist/assets/react-BB4jko2M.js +1 -0
  38. package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-CA4_C7Zg.js} +1 -1
  39. package/dist/assets/{save-4VRlzkii.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-DF-AUoie.js → select-xp_Ac8ip.js} +1 -1
  43. package/dist/assets/skeleton-uxz_5h3A.js +1 -0
  44. package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-Cn4Pp7DZ.js} +1 -1
  45. package/dist/assets/{switch-D7JF_RZ-.js → switch-BTi6UOij.js} +1 -1
  46. package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-BiiN8DME.js} +1 -1
  47. package/dist/assets/{trash-2-VV8jvziy.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-CuQqiPx7.js → useConfirmDialog-BJIwUZjH.js} +1 -1
  50. package/dist/assets/{useMutation-DBTWPbTg.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 +5 -5
  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/ChatSidebar.test.tsx +168 -28
  60. package/src/components/chat/ChatSidebar.tsx +103 -28
  61. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  62. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  63. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  64. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  65. package/src/components/chat/chat-child-session-panel.tsx +103 -45
  66. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  67. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  68. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  69. package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
  70. package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
  71. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  72. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  73. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  74. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  75. package/src/components/chat/managers/chat-session-list.manager.test.ts +46 -6
  76. package/src/components/chat/managers/chat-session-list.manager.ts +19 -6
  77. package/src/components/chat/ncp/NcpChatPage.tsx +33 -38
  78. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  79. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  80. package/src/components/chat/ncp/ncp-chat.presenter.ts +2 -16
  81. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
  82. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
  83. package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
  84. package/src/components/chat/stores/chat-session-list.store.ts +3 -0
  85. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  86. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  87. package/src/components/config/ChannelsList.test.tsx +68 -0
  88. package/src/components/config/ChannelsList.tsx +22 -4
  89. package/src/components/config/ProvidersList.tsx +17 -3
  90. package/src/components/config/providers-list.test.tsx +68 -0
  91. package/src/components/layout/Sidebar.tsx +13 -13
  92. package/src/components/layout/sidebar.layout.test.tsx +32 -1
  93. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  94. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  95. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  96. package/src/hooks/marketplace-list-pages.ts +27 -0
  97. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  98. package/src/hooks/useMarketplace.ts +14 -3
  99. package/src/hooks/useMcpMarketplace.ts +14 -3
  100. package/src/lib/i18n.chat.ts +3 -0
  101. package/src/lib/i18n.remote.ts +15 -0
  102. package/dist/assets/ChatPage-YBL7iJ1X.js +0 -43
  103. package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
  104. package/dist/assets/MarketplacePage-2tWWgwAb.js +0 -49
  105. package/dist/assets/MarketplacePage-BorWJftJ.js +0 -1
  106. package/dist/assets/McpMarketplacePage-N-fB4HID.js +0 -40
  107. package/dist/assets/ModelConfig-DvsBTUiE.js +0 -1
  108. package/dist/assets/ProviderScopedModelInput-D9woCARc.js +0 -1
  109. package/dist/assets/ProvidersList-D-qPGgC4.js +0 -1
  110. package/dist/assets/RuntimeConfig-BHpqcaHm.js +0 -1
  111. package/dist/assets/SearchConfig-DIT6M65Q.js +0 -1
  112. package/dist/assets/i18n-hM3v-3YG.js +0 -1
  113. package/dist/assets/index-CpxuJa9o.css +0 -1
  114. package/dist/assets/index-DHmCjcxq.js +0 -6
  115. package/dist/assets/label-CHJ1ATds.js +0 -1
  116. package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
  117. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  118. package/dist/assets/plus-CrkO1kob.js +0 -1
  119. package/dist/assets/react-3YE87-lE.js +0 -1
  120. package/dist/assets/search-EX-Papzl.js +0 -1
  121. package/dist/assets/security-config-DEgOD4VX.js +0 -1
  122. package/dist/assets/skeleton-B0mmt1vo.js +0 -1
  123. package/dist/assets/x-B4sxJkGY.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,15 +134,13 @@ 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;
141
+ const sessionKey = routeSessionKey ?? selectedSessionKey ?? draftSessionKey;
134
142
  const hasSessionProjectRootOverride =
143
+ pendingProjectRoot !== null &&
135
144
  pendingProjectRootSessionKey === sessionKey;
136
145
  const sessionProjectRootOverride = hasSessionProjectRootOverride
137
146
  ? pendingProjectRoot
@@ -163,25 +172,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
163
172
 
164
173
  const agent = useNcpSessionConversation(sessionKey);
165
174
 
166
- useEffect(() => {
167
- presenter.setDraftSessionId(draftSessionId);
168
- }, [draftSessionId, presenter]);
169
-
170
- useEffect(() => {
171
- if (
172
- shouldRefreshDraftSessionId({
173
- previousSelectedSessionKey:
174
- previousSelectedSessionKeyRef.current,
175
- nextSelectedSessionKey: selectedSessionKey,
176
- })
177
- ) {
178
- const nextDraftSessionId = createNcpSessionId();
179
- setDraftSessionId(nextDraftSessionId);
180
- presenter.setDraftSessionId(nextDraftSessionId);
181
- }
182
- previousSelectedSessionKeyRef.current = selectedSessionKey;
183
- }, [presenter, selectedSessionKey]);
184
-
185
175
  const effectiveSessionProjectRoot = hasSessionProjectRootOverride
186
176
  ? pendingProjectRoot
187
177
  : (selectedSession?.projectRoot ?? null);
@@ -265,6 +255,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
265
255
  pendingProjectRoot,
266
256
  pendingProjectRootSessionKey,
267
257
  presenter,
258
+ selectedSessionKey,
268
259
  selectedSession?.projectRoot,
269
260
  sessionKey,
270
261
  ]);
@@ -272,8 +263,12 @@ export function NcpChatPage({ view }: ChatPageProps) {
272
263
  useEffect(() => {
273
264
  if (
274
265
  !selectedSession ||
275
- pendingProjectRootSessionKey !== selectedSession.key ||
276
- (selectedSession.projectRoot ?? null) !== pendingProjectRoot
266
+ !shouldClearPendingProjectRootOverride({
267
+ pendingProjectRoot,
268
+ pendingProjectRootSessionKey,
269
+ sessionKey: selectedSession.key,
270
+ selectedSessionProjectRoot: selectedSession.projectRoot ?? null,
271
+ })
277
272
  ) {
278
273
  return;
279
274
  }
@@ -281,7 +276,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
281
276
  pendingProjectRoot: null,
282
277
  pendingProjectRootSessionKey: null,
283
278
  });
284
- }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession]);
279
+ }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession, selectedSessionKey]);
285
280
 
286
281
  useChatSessionSync({
287
282
  view,
@@ -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
@@ -7,29 +7,15 @@ import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.mana
7
7
  export class NcpChatPresenter {
8
8
  chatUiManager = new ChatUiManager();
9
9
  chatStreamActionsManager = new ChatStreamActionsManager();
10
- chatSessionListManager = new ChatSessionListManager(
11
- this.chatUiManager,
12
- this.chatStreamActionsManager,
13
- () => this.getDraftSessionId()
14
- );
10
+ chatSessionListManager = new ChatSessionListManager(this.chatUiManager, this.chatStreamActionsManager);
15
11
  chatInputManager = new NcpChatInputManager(
16
12
  this.chatUiManager,
17
13
  this.chatStreamActionsManager,
18
- () => this.getDraftSessionId()
14
+ this.chatSessionListManager
19
15
  );
20
16
  chatThreadManager = new NcpChatThreadManager(
21
17
  this.chatUiManager,
22
18
  this.chatSessionListManager,
23
19
  this.chatStreamActionsManager
24
20
  );
25
-
26
- private draftSessionId = '';
27
-
28
- setDraftSessionId = (sessionId: string) => {
29
- this.draftSessionId = sessionId;
30
- };
31
-
32
- private getDraftSessionId(): string {
33
- return this.draftSessionId;
34
- }
35
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],
@@ -40,6 +40,22 @@ describe('ChatSessionProjectBadge', () => {
40
40
  expect(screen.getByText('/tmp/project-alpha')).toBeTruthy();
41
41
  });
42
42
 
43
+ it('uses the neutral header tag styling instead of a highlighted accent color', () => {
44
+ render(
45
+ <ChatSessionProjectBadge
46
+ sessionKey="session-1"
47
+ projectName="project-alpha"
48
+ projectRoot="/tmp/project-alpha"
49
+ persistToServer
50
+ />
51
+ );
52
+
53
+ const trigger = screen.getByRole('button', { name: 'Set Project Directory' });
54
+ expect(trigger.className).toContain('border-gray-200');
55
+ expect(trigger.className).toContain('text-gray-600');
56
+ expect(trigger.className).not.toContain('emerald');
57
+ });
58
+
43
59
  it('clears the current project from the badge popover', async () => {
44
60
  const user = userEvent.setup();
45
61
 
@@ -46,7 +46,7 @@ export function ChatSessionProjectBadge({
46
46
  <button
47
47
  type="button"
48
48
  title={projectRoot ?? undefined}
49
- className="min-w-0 max-w-[320px] shrink rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 transition-colors hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
49
+ className="min-w-0 max-w-[320px] shrink rounded-full border border-gray-200 bg-gray-100/90 px-2 py-0.5 text-[11px] font-medium text-gray-600 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60"
50
50
  aria-label={t('chatSessionSetProject')}
51
51
  disabled={isProjectPending}
52
52
  >
@@ -59,7 +59,7 @@ export function ChatSessionProjectBadge({
59
59
  </PopoverTrigger>
60
60
  <PopoverContent align="start" className="w-72 p-2">
61
61
  <div className="px-3 pb-2 pt-1">
62
- <div className="text-[11px] font-medium uppercase tracking-wider text-emerald-700/80">
62
+ <div className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
63
63
  {projectName}
64
64
  </div>
65
65
  {projectRoot ? (
@@ -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
+ });