@nextclaw/ui 0.11.23 → 0.12.1

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 (118) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-DekMP4a3.js} +1 -1
  3. package/dist/assets/ChatPage-Dgw4vlDt.js +43 -0
  4. package/dist/assets/DocBrowser-CExjX5is.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DjcghYGO.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CLlq7rZQ.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D_dOy5U3.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-BlIeNn3x.js} +2 -2
  9. package/dist/assets/MarketplacePage-DGfzg1LG.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-mz2_IX1O.js} +2 -2
  11. package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-C_49_a9v.js} +1 -1
  12. package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-B0RCb_Vg.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-CcfQjLtx.js} +1 -1
  14. package/dist/assets/RuntimeConfig-DBWzwoY-.js +1 -0
  15. package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-jSdwlH4b.js} +1 -1
  16. package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-DbiS3txa.js} +1 -1
  17. package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-CbIOcAp8.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-BLxSL7Dk.js} +1 -1
  19. package/dist/assets/chat-session-display-8yW6-mtm.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-Bp0t5xoO.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-C96FWufn.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-B_U7Nq4F.js} +1 -1
  23. package/dist/assets/{dist-Cl2QB-2y.js → dist-BFY-GyT4.js} +1 -1
  24. package/dist/assets/{dist-nqTTbVdA.js → dist-D9pHzW9z.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BydIQTIH.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-Djdf0x1C.js} +1 -1
  27. package/dist/assets/i18n-DAekxt_G.js +1 -0
  28. package/dist/assets/index-CHEgQIiO.css +1 -0
  29. package/dist/assets/index-DqSv8Azv.js +6 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-Bvv4Mrea.js} +1 -1
  31. package/dist/assets/loader-circle-CGXXikVG.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CGJJRI5_.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-6Nm4Cnvr.js} +1 -1
  34. package/dist/assets/plus-CrW9BJDy.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-b9rSYI6X.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-IJDA940D.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-CDZz_StC.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BvSSnnCw.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-CAf0_-b9.js} +1 -1
  40. package/dist/assets/search-DgoXxocn.js +1 -0
  41. package/dist/assets/{security-config-DbUyWcQz.js → security-config-DF66-l25.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-CEIMqc0H.js} +1 -1
  43. package/dist/assets/skeleton-BiPUQkOD.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-CmQI5Qq2.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B7SxDXyR.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-Dxt6EJJW.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-BnQ1PDTw.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-B-vMOmhG.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-Bi39Z9_J.js} +1 -1
  50. package/dist/assets/x-PBSiWt3l.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +7 -7
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +23 -5
  56. package/src/components/agents/AgentsPage.test.tsx +70 -0
  57. package/src/components/agents/AgentsPage.tsx +353 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +172 -13
  59. package/src/components/chat/ChatConversationPanel.tsx +30 -7
  60. package/src/components/chat/ChatSidebar.test.tsx +48 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +30 -0
  63. package/src/components/chat/ChatWelcome.tsx +50 -1
  64. package/src/components/chat/adapters/chat-message-part.adapter.ts +5 -0
  65. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +102 -0
  66. package/src/components/chat/adapters/chat-message-tool-agent-id.ts +47 -0
  67. package/src/components/chat/adapters/chat-message.adapter.test.ts +6 -0
  68. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +24 -15
  69. package/src/components/chat/chat-child-session-panel.tsx +115 -49
  70. package/src/components/chat/chat-page-runtime.test.ts +30 -0
  71. package/src/components/chat/chat-page-shell.tsx +8 -17
  72. package/src/components/chat/chat-session-route.ts +0 -14
  73. package/src/components/chat/chat-sidebar-session-item.tsx +20 -1
  74. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  75. package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
  76. package/src/components/chat/ncp/NcpChatPage.tsx +77 -158
  77. package/src/components/chat/ncp/README.md +3 -0
  78. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  79. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
  80. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  81. package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
  82. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  83. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  85. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  86. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  87. package/src/components/chat/stores/chat-thread.store.ts +20 -6
  88. package/src/components/common/AgentAvatar.tsx +63 -0
  89. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  90. package/src/components/common/agent-identity/index.ts +3 -0
  91. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  92. package/src/components/config/RuntimeConfig.tsx +14 -101
  93. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  94. package/src/components/layout/AppLayout.tsx +3 -1
  95. package/src/components/layout/Sidebar.tsx +6 -1
  96. package/src/components/layout/app-layout.test.tsx +30 -0
  97. package/src/components/ui/tabs.tsx +2 -0
  98. package/src/hooks/README.md +3 -0
  99. package/src/hooks/agents/useAgents.ts +44 -0
  100. package/src/lib/i18n.agents.ts +66 -0
  101. package/src/lib/i18n.chat.ts +5 -0
  102. package/src/lib/i18n.ts +4 -6
  103. package/src/lib/ui-document-title.ts +1 -0
  104. package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
  105. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  106. package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
  107. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
  108. package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
  109. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  110. package/dist/assets/index-BuwbBgmT.js +0 -6
  111. package/dist/assets/index-bZ8cqQIS.css +0 -1
  112. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  113. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  114. package/dist/assets/search-C91yH_6y.js +0 -1
  115. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  116. package/dist/assets/x-D7Q1yqSF.js +0 -1
  117. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  118. /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
@@ -1,48 +1,36 @@
1
1
  import {
2
- useCallback,
3
2
  useEffect,
4
3
  useMemo,
5
4
  useRef,
6
5
  useState,
7
- type MutableRefObject,
8
6
  } from "react";
9
- import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
10
7
  import {
11
8
  buildNcpRequestEnvelope,
12
- useHydratedNcpAgent,
13
- type NcpConversationSeed,
14
- type NcpConversationSeedLoader,
15
9
  } from "@nextclaw/ncp-react";
16
10
  import { useLocation, useNavigate, useParams } from "react-router-dom";
17
- import { API_BASE } from "@/api/api-base";
18
- import { fetchNcpSessionMessages } from "@/api/ncp-session";
19
11
  import {
20
12
  ChatPageLayout,
21
13
  type ChatPageProps,
22
14
  useChatSessionSync,
23
15
  } from "@/components/chat/chat-page-shell";
24
- import { sessionDisplayName } from "@/components/chat/chat-session-display";
25
16
  import {
26
17
  buildInlineSkillTokensFromComposer,
27
18
  CHAT_UI_INLINE_TOKENS_METADATA_KEY,
28
19
  } from "@/components/chat/chat-inline-token.utils";
29
- import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
30
20
  import {
31
21
  parseSessionKeyFromRoute,
32
- resolveAgentIdFromSessionKey,
33
22
  } from "@/components/chat/chat-session-route";
34
23
  import { useNcpChatPageData } from "@/components/chat/ncp/ncp-chat-page-data";
35
24
  import { NcpChatPresenter } from "@/components/chat/ncp/ncp-chat.presenter";
36
- import {
37
- adaptNcpSessionSummary,
38
- createNcpSessionId,
39
- } from "@/components/chat/ncp/ncp-session-adapter";
25
+ import { createNcpSessionId } from "@/components/chat/ncp/ncp-session-adapter";
26
+ import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
27
+ import { useNcpChatDerivedState, useNcpChatSnapshotSync } from "@/components/chat/ncp/page/ncp-chat-derived-state";
40
28
  import { ChatPresenterProvider } from "@/components/chat/presenter/chat-presenter-context";
41
29
  import type { ResumeRunParams } from "@/components/chat/chat-stream/types";
42
30
  import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
43
31
  import { useChatSessionListStore } from "@/components/chat/stores/chat-session-list.store";
44
- import { resolveSessionTypeLabel } from "@/components/chat/useChatSessionTypeState";
45
32
  import { useConfirmDialog } from "@/hooks/useConfirmDialog";
33
+ import { useAgents } from "@/hooks/agents/useAgents";
46
34
  import { normalizeRequestedSkills } from "@/lib/chat-runtime-utils";
47
35
  import {
48
36
  getSessionProjectName,
@@ -50,6 +38,7 @@ import {
50
38
  } from "@/lib/session-project/session-project.utils";
51
39
 
52
40
  export function buildNcpSendMetadata(payload: {
41
+ agentId?: string;
53
42
  model?: string;
54
43
  thinkingLevel?: string;
55
44
  sessionType?: string;
@@ -69,6 +58,9 @@ export function buildNcpSendMetadata(payload: {
69
58
  if (payload.sessionType?.trim()) {
70
59
  metadata.session_type = payload.sessionType.trim();
71
60
  }
61
+ if (payload.agentId?.trim()) {
62
+ metadata.agent_id = payload.agentId.trim();
63
+ }
72
64
  const projectRoot = normalizeSessionProjectRootValue(payload.projectRoot);
73
65
  if (projectRoot) {
74
66
  metadata.project_root = projectRoot;
@@ -86,48 +78,14 @@ export function buildNcpSendMetadata(payload: {
86
78
  return metadata;
87
79
  }
88
80
 
89
- function isMissingNcpSessionError(error: unknown): boolean {
90
- if (!(error instanceof Error)) {
91
- return false;
92
- }
93
- return error.message.includes("ncp session not found:");
94
- }
95
-
96
- type NcpSeedSessionSummary = {
97
- sessionId: string;
98
- status?: string;
99
- };
100
-
101
- function useNcpConversationSeedLoader<T extends NcpSeedSessionSummary>(
102
- sessionSummariesRef: MutableRefObject<readonly T[]>,
103
- ): NcpConversationSeedLoader {
104
- return useCallback(
105
- async (
106
- sessionId: string,
107
- signal: AbortSignal,
108
- ): Promise<NcpConversationSeed> => {
109
- signal.throwIfAborted();
110
- let history: Awaited<ReturnType<typeof fetchNcpSessionMessages>> | null =
111
- null;
112
- try {
113
- history = await fetchNcpSessionMessages(sessionId, 300);
114
- } catch (error) {
115
- if (!isMissingNcpSessionError(error)) {
116
- throw error;
117
- }
118
- }
119
- signal.throwIfAborted();
120
-
121
- const sessionSummary =
122
- sessionSummariesRef.current.find(
123
- (item) => item.sessionId === sessionId,
124
- ) ?? null;
125
- return {
126
- messages: history?.messages ?? [],
127
- status: sessionSummary?.status === "running" ? "running" : "idle",
128
- };
129
- },
130
- [sessionSummariesRef],
81
+ export function shouldRefreshDraftSessionId(params: {
82
+ previousSelectedSessionKey: string | null | undefined;
83
+ nextSelectedSessionKey: string | null;
84
+ }): boolean {
85
+ return (
86
+ params.nextSelectedSessionKey === null &&
87
+ params.previousSelectedSessionKey !== undefined &&
88
+ params.previousSelectedSessionKey !== null
131
89
  );
132
90
  }
133
91
 
@@ -152,6 +110,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
152
110
  const pendingProjectRootSessionKey = useChatInputStore(
153
111
  (state) => state.snapshot.pendingProjectRootSessionKey,
154
112
  );
113
+ const agentsQuery = useAgents();
155
114
  const currentSelectedModel = useChatInputStore(
156
115
  (state) => state.snapshot.selectedModel,
157
116
  );
@@ -163,6 +122,9 @@ export function NcpChatPage({ view }: ChatPageProps) {
163
122
  }>();
164
123
  const threadRef = useRef<HTMLDivElement | null>(null);
165
124
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
125
+ const previousSelectedSessionKeyRef = useRef<string | null | undefined>(
126
+ undefined,
127
+ );
166
128
  const routeSessionKey = useMemo(
167
129
  () => parseSessionKeyFromRoute(routeSessionIdParam),
168
130
  [routeSessionIdParam],
@@ -198,38 +160,25 @@ export function NcpChatPage({ view }: ChatPageProps) {
198
160
  presenter.chatInputManager.setSelectedThinkingLevel,
199
161
  });
200
162
 
201
- const sessionSummariesRef = useRef(sessionSummaries);
202
- useEffect(() => {
203
- sessionSummariesRef.current = sessionSummaries;
204
- }, [sessionSummaries]);
205
-
206
- const [ncpClient] = useState(
207
- () =>
208
- new NcpHttpAgentClientEndpoint({
209
- baseUrl: API_BASE,
210
- basePath: "/api/ncp/agent",
211
- fetchImpl: createNcpAppClientFetch(),
212
- }),
213
- );
214
-
215
- const loadSeed = useNcpConversationSeedLoader(sessionSummariesRef);
216
-
217
- const agent = useHydratedNcpAgent({
218
- sessionId: sessionKey,
219
- client: ncpClient,
220
- loadSeed,
221
- });
163
+ const agent = useNcpSessionConversation(sessionKey);
222
164
 
223
165
  useEffect(() => {
224
166
  presenter.setDraftSessionId(draftSessionId);
225
167
  }, [draftSessionId, presenter]);
226
168
 
227
169
  useEffect(() => {
228
- if (selectedSessionKey === null) {
170
+ if (
171
+ shouldRefreshDraftSessionId({
172
+ previousSelectedSessionKey:
173
+ previousSelectedSessionKeyRef.current,
174
+ nextSelectedSessionKey: selectedSessionKey,
175
+ })
176
+ ) {
229
177
  const nextDraftSessionId = createNcpSessionId();
230
178
  setDraftSessionId(nextDraftSessionId);
231
179
  presenter.setDraftSessionId(nextDraftSessionId);
232
180
  }
181
+ previousSelectedSessionKeyRef.current = selectedSessionKey;
233
182
  }, [presenter, selectedSessionKey]);
234
183
 
235
184
  const effectiveSessionProjectRoot = hasSessionProjectRootOverride
@@ -255,6 +204,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
255
204
  return;
256
205
  }
257
206
  const metadata = buildNcpSendMetadata({
207
+ agentId: payload.agentId,
258
208
  model: payload.model,
259
209
  thinkingLevel: payload.thinkingLevel,
260
210
  sessionType: payload.sessionType,
@@ -336,13 +286,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
336
286
  view,
337
287
  routeSessionKey,
338
288
  selectedSessionKey,
339
- selectedAgentId,
340
289
  setSelectedSessionKey:
341
290
  presenter.chatSessionListManager.setSelectedSessionKey,
342
- setSelectedAgentId: presenter.chatSessionListManager.setSelectedAgentId,
343
291
  selectedSessionKeyRef,
344
292
  resetStreamState: presenter.chatStreamActionsManager.resetStreamState,
345
- resolveAgentIdFromSessionKey,
346
293
  });
347
294
 
348
295
  useEffect(() => {
@@ -355,90 +302,62 @@ export function NcpChatPage({ view }: ChatPageProps) {
355
302
  });
356
303
  }, [confirm, location.pathname, navigate, presenter]);
357
304
 
358
- const currentSessionDisplayName = selectedSession
359
- ? sessionDisplayName(selectedSession)
360
- : undefined;
361
- const parentSession = useMemo(() => {
362
- if (!parentSessionId) {
363
- return null;
364
- }
365
- const parentSummary =
366
- sessionSummaries.find(
367
- (summary) => summary.sessionId === parentSessionId,
368
- ) ?? null;
369
- return parentSummary ? adaptNcpSessionSummary(parentSummary) : null;
370
- }, [parentSessionId, sessionSummaries]);
371
- const currentSessionTypeLabel =
372
- sessionTypeOptions.find((option) => option.value === selectedSessionType)
373
- ?.label ?? resolveSessionTypeLabel(selectedSessionType);
305
+ const availableAgents = (agentsQuery.data?.agents?.length ?? 0) > 0
306
+ ? (agentsQuery.data?.agents ?? [])
307
+ : [{ id: selectedSession?.agentId ?? selectedAgentId }];
308
+ const {
309
+ currentSessionDisplayName,
310
+ currentAgentId,
311
+ currentAgent,
312
+ parentSession,
313
+ currentSessionTypeLabel
314
+ } = useNcpChatDerivedState({
315
+ selectedSession,
316
+ selectedAgentId,
317
+ availableAgents,
318
+ parentSessionId,
319
+ sessionSummaries,
320
+ selectedSessionType,
321
+ sessionTypeOptions
322
+ });
374
323
 
375
324
  useEffect(() => {
376
- presenter.chatInputManager.syncSnapshot({
377
- isProviderStateResolved,
378
- defaultSessionType,
379
- canStopGeneration: canStopCurrentRun,
380
- stopDisabledReason,
381
- stopSupported: true,
382
- stopReason: undefined,
383
- sendError: lastSendError,
384
- isSending,
385
- modelOptions,
386
- sessionTypeOptions,
387
- selectedSessionType,
388
- canEditSessionType,
389
- sessionTypeUnavailable,
390
- skillRecords,
391
- isSkillsLoading: sessionSkillsQuery.isLoading,
392
- });
393
- presenter.chatThreadManager.syncSnapshot({
394
- isProviderStateResolved,
395
- modelOptions,
396
- sessionTypeUnavailable,
397
- sessionTypeUnavailableMessage,
398
- sessionTypeLabel: currentSessionTypeLabel,
399
- sessionKey,
400
- sessionDisplayName: currentSessionDisplayName,
401
- sessionProjectRoot: effectiveSessionProjectRoot,
402
- sessionProjectName: effectiveSessionProjectName,
403
- canDeleteSession: Boolean(selectedSession),
404
- threadRef,
405
- isHistoryLoading: agent.isHydrating,
406
- messages: agent.visibleMessages,
407
- isSending,
408
- isAwaitingAssistantOutput,
409
- parentSessionKey: parentSession?.key ?? null,
410
- parentSessionLabel: parentSession
411
- ? sessionDisplayName(parentSession)
412
- : null,
413
- });
414
- }, [
415
- agent.isHydrating,
416
- canEditSessionType,
417
- canStopCurrentRun,
418
- currentSessionDisplayName,
419
- currentSessionTypeLabel,
420
- defaultSessionType,
421
- sessionSkillsQuery.isLoading,
422
- isAwaitingAssistantOutput,
325
+ if (!selectedSession?.agentId || selectedAgentId === selectedSession.agentId) {
326
+ return;
327
+ }
328
+ presenter.chatSessionListManager.setSelectedAgentId(selectedSession.agentId);
329
+ }, [presenter, selectedAgentId, selectedSession?.agentId]);
330
+
331
+ useNcpChatSnapshotSync({
332
+ presenter,
423
333
  isProviderStateResolved,
424
- isSending,
334
+ defaultSessionType,
335
+ canStopCurrentRun,
336
+ stopDisabledReason,
425
337
  lastSendError,
338
+ isSending,
426
339
  modelOptions,
427
- parentSession,
428
- presenter,
429
- effectiveSessionProjectName,
430
- effectiveSessionProjectRoot,
431
- selectedSession,
432
- sessionKey,
433
- selectedSessionType,
434
340
  sessionTypeOptions,
341
+ selectedSessionType,
342
+ canEditSessionType,
435
343
  sessionTypeUnavailable,
436
- sessionTypeUnavailableMessage,
437
344
  skillRecords,
438
- stopDisabledReason,
345
+ isSkillsLoading: sessionSkillsQuery.isLoading,
346
+ sessionTypeUnavailableMessage,
347
+ currentSessionTypeLabel,
348
+ sessionKey,
349
+ currentAgentId,
350
+ currentAgent,
351
+ availableAgents,
352
+ currentSessionDisplayName,
353
+ effectiveSessionProjectRoot,
354
+ effectiveSessionProjectName,
355
+ selectedSession,
439
356
  threadRef,
440
- agent.visibleMessages,
441
- ]);
357
+ agent,
358
+ isAwaitingAssistantOutput,
359
+ parentSession
360
+ });
442
361
 
443
362
  return (
444
363
  <ChatPresenterProvider presenter={presenter}>
@@ -0,0 +1,3 @@
1
+ ## 子树边界豁免
2
+
3
+ - 原因:`chat/ncp/` 目录是 NCP 聊天运行时的装配子树,需要并列保留页面数据、派生状态、输入/线程 manager、session adapter 与测试文件。本次新增派生状态模块是为了拆短 `NcpChatPage.tsx`,属于职责下沉,而不是继续把复杂度堆回页面壳。
@@ -48,10 +48,12 @@ describe('buildNcpSendMetadata', () => {
48
48
  it('includes the project root in the first-message metadata when present', () => {
49
49
  expect(
50
50
  buildNcpSendMetadata({
51
+ agentId: 'engineer',
51
52
  sessionType: 'codex',
52
53
  projectRoot: ' /tmp/project-alpha ',
53
54
  }),
54
55
  ).toMatchObject({
56
+ agent_id: 'engineer',
55
57
  session_type: 'codex',
56
58
  project_root: '/tmp/project-alpha',
57
59
  });
@@ -6,7 +6,10 @@ import type { ChatSessionListManager } from '@/components/chat/managers/chat-ses
6
6
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
7
7
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
8
8
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
9
- import type { ChatThreadSnapshot } from '@/components/chat/stores/chat-thread.store';
9
+ import type {
10
+ ChatChildSessionTab,
11
+ ChatThreadSnapshot,
12
+ } from '@/components/chat/stores/chat-thread.store';
10
13
  import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
11
14
  import { t } from '@/lib/i18n';
12
15
 
@@ -46,6 +49,23 @@ export class NcpChatThreadManager {
46
49
  this.uiManager.goToProviders();
47
50
  };
48
51
 
52
+ private upsertChildSessionTab = (tab: ChatChildSessionTab) => {
53
+ const { snapshot } = useChatThreadStore.getState();
54
+ const existingIndex = snapshot.childSessionTabs.findIndex(
55
+ (item) => item.sessionKey === tab.sessionKey,
56
+ );
57
+ const nextTabs =
58
+ existingIndex >= 0
59
+ ? snapshot.childSessionTabs.map((item, index) =>
60
+ index === existingIndex ? { ...item, ...tab } : item,
61
+ )
62
+ : [...snapshot.childSessionTabs, tab];
63
+ useChatThreadStore.getState().setSnapshot({
64
+ childSessionTabs: nextTabs,
65
+ activeChildSessionKey: tab.sessionKey,
66
+ });
67
+ };
68
+
49
69
  openSessionFromToolAction = (action: ChatToolActionViewModel) => {
50
70
  if (action.kind !== 'open-session') {
51
71
  return;
@@ -55,31 +75,67 @@ export class NcpChatThreadManager {
55
75
  action.parentSessionId?.trim() ||
56
76
  useChatSessionListStore.getState().snapshot.selectedSessionKey ||
57
77
  null;
58
- useChatThreadStore.getState().setSnapshot({
59
- childSessionDetailSessionKey: action.sessionId,
60
- childSessionDetailParentSessionKey: parentSessionKey,
61
- childSessionDetailLabel: action.label?.trim() || null,
78
+ this.upsertChildSessionTab({
79
+ sessionKey: action.sessionId,
80
+ parentSessionKey,
81
+ label: action.label?.trim() || null,
82
+ agentId: action.agentId?.trim() || null,
62
83
  });
63
84
  return;
64
85
  }
65
86
  this.uiManager.goToSession(action.sessionId);
66
87
  };
67
88
 
89
+ selectChildSessionDetail = (sessionKey: string) => {
90
+ const normalizedSessionKey = sessionKey.trim();
91
+ if (!normalizedSessionKey) {
92
+ return;
93
+ }
94
+ const { childSessionTabs } = useChatThreadStore.getState().snapshot;
95
+ if (!childSessionTabs.some((tab) => tab.sessionKey === normalizedSessionKey)) {
96
+ return;
97
+ }
98
+ useChatThreadStore.getState().setSnapshot({
99
+ activeChildSessionKey: normalizedSessionKey,
100
+ });
101
+ };
102
+
68
103
  closeChildSessionDetail = () => {
104
+ const {
105
+ sessionKey,
106
+ childSessionTabs,
107
+ activeChildSessionKey,
108
+ } = useChatThreadStore.getState().snapshot;
109
+ if (!sessionKey) {
110
+ useChatThreadStore.getState().setSnapshot({
111
+ childSessionTabs: [],
112
+ activeChildSessionKey: null,
113
+ });
114
+ return;
115
+ }
116
+ const nextTabs = childSessionTabs.filter(
117
+ (tab) => tab.parentSessionKey !== sessionKey,
118
+ );
119
+ const nextActiveKey = nextTabs.some((tab) => tab.sessionKey === activeChildSessionKey)
120
+ ? activeChildSessionKey
121
+ : null;
69
122
  useChatThreadStore.getState().setSnapshot({
70
- childSessionDetailSessionKey: null,
71
- childSessionDetailParentSessionKey: null,
72
- childSessionDetailLabel: null,
123
+ childSessionTabs: nextTabs,
124
+ activeChildSessionKey: nextActiveKey,
73
125
  });
74
126
  };
75
127
 
76
128
  goToParentSession = () => {
77
129
  const {
78
130
  parentSessionKey,
79
- childSessionDetailParentSessionKey,
131
+ childSessionTabs,
132
+ activeChildSessionKey,
80
133
  } = useChatThreadStore.getState().snapshot;
134
+ const activeChildParentSessionKey =
135
+ childSessionTabs.find((tab) => tab.sessionKey === activeChildSessionKey)
136
+ ?.parentSessionKey ?? null;
81
137
  const resolvedParentSessionKey =
82
- parentSessionKey ?? childSessionDetailParentSessionKey;
138
+ parentSessionKey ?? activeChildParentSessionKey;
83
139
  if (!resolvedParentSessionKey) {
84
140
  return;
85
141
  }
@@ -20,6 +20,7 @@ describe('adaptNcpSessionSummary', () => {
20
20
  it('maps session metadata into shared session entry fields', () => {
21
21
  const adapted = adaptNcpSessionSummary(
22
22
  createSummary({
23
+ agentId: 'engineer',
23
24
  metadata: {
24
25
  label: 'NCP Planning Thread',
25
26
  model: 'openai/gpt-5',
@@ -32,6 +33,7 @@ describe('adaptNcpSessionSummary', () => {
32
33
 
33
34
  expect(adapted).toMatchObject({
34
35
  key: 'ncp-session-1',
36
+ agentId: 'engineer',
35
37
  label: 'NCP Planning Thread',
36
38
  preferredModel: 'openai/gpt-5',
37
39
  preferredThinking: 'medium',
@@ -253,6 +253,7 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
253
253
  key: summary.sessionId,
254
254
  createdAt: summary.updatedAt,
255
255
  updatedAt: summary.updatedAt,
256
+ ...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
256
257
  ...(label ? { label } : {}),
257
258
  ...context,
258
259
  ...(preferredModel ? { preferredModel } : {}),
@@ -0,0 +1,128 @@
1
+ import { useEffect, useMemo, type MutableRefObject } from 'react';
2
+ import type {
3
+ AgentProfileView,
4
+ NcpSessionSummaryView,
5
+ SessionEntryView,
6
+ SessionSkillEntryView
7
+ } from '@/api/types';
8
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
9
+ import { adaptNcpSessionSummary } from '@/components/chat/ncp/ncp-session-adapter';
10
+ import type { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
11
+ import type { UseHydratedNcpAgentResult } from '@nextclaw/ncp-react';
12
+ import type { ChatModelOption } from '@/components/chat/chat-input.types';
13
+ import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
14
+ export function useNcpChatDerivedState(params: {
15
+ selectedSession: SessionEntryView | null;
16
+ selectedAgentId: string;
17
+ availableAgents: AgentProfileView[];
18
+ parentSessionId: string | null;
19
+ sessionSummaries: NcpSessionSummaryView[];
20
+ selectedSessionType: string;
21
+ sessionTypeOptions: Array<{ value: string; label: string }>;
22
+ }) {
23
+ const currentSessionDisplayName = params.selectedSession
24
+ ? sessionDisplayName(params.selectedSession)
25
+ : undefined;
26
+ const currentAgentId = params.selectedSession?.agentId ?? params.selectedAgentId;
27
+ const currentAgent =
28
+ params.availableAgents.find((agent) => agent.id === currentAgentId) ?? null;
29
+ const parentSession = useMemo(() => {
30
+ if (!params.parentSessionId) {
31
+ return null;
32
+ }
33
+ const parentSummary =
34
+ params.sessionSummaries.find(
35
+ (summary) => summary.sessionId === params.parentSessionId,
36
+ ) ?? null;
37
+ return parentSummary ? adaptNcpSessionSummary(parentSummary) : null;
38
+ }, [params.parentSessionId, params.sessionSummaries]);
39
+ const currentSessionTypeLabel =
40
+ params.sessionTypeOptions.find((option) => option.value === params.selectedSessionType)
41
+ ?.label ?? resolveSessionTypeLabel(params.selectedSessionType);
42
+
43
+ return {
44
+ currentSessionDisplayName,
45
+ currentAgentId,
46
+ currentAgent,
47
+ parentSession,
48
+ currentSessionTypeLabel
49
+ };
50
+ }
51
+
52
+ export function useNcpChatSnapshotSync(params: {
53
+ presenter: NcpChatPresenter;
54
+ isProviderStateResolved: boolean;
55
+ defaultSessionType: string;
56
+ canStopCurrentRun: boolean;
57
+ stopDisabledReason: string | null;
58
+ lastSendError: string | null;
59
+ isSending: boolean;
60
+ modelOptions: ChatModelOption[];
61
+ sessionTypeOptions: Array<{ value: string; label: string }>;
62
+ selectedSessionType: string;
63
+ canEditSessionType: boolean;
64
+ sessionTypeUnavailable: boolean;
65
+ skillRecords: SessionSkillEntryView[];
66
+ isSkillsLoading: boolean;
67
+ sessionTypeUnavailableMessage: string | null;
68
+ currentSessionTypeLabel: string;
69
+ sessionKey: string;
70
+ currentAgentId: string;
71
+ currentAgent: AgentProfileView | null;
72
+ availableAgents: AgentProfileView[];
73
+ currentSessionDisplayName?: string;
74
+ effectiveSessionProjectRoot: string | null;
75
+ effectiveSessionProjectName: string | null;
76
+ selectedSession: SessionEntryView | null;
77
+ threadRef: MutableRefObject<HTMLDivElement | null>;
78
+ agent: Pick<UseHydratedNcpAgentResult, 'isHydrating' | 'visibleMessages'>;
79
+ isAwaitingAssistantOutput: boolean;
80
+ parentSession: SessionEntryView | null;
81
+ }) {
82
+ useEffect(() => {
83
+ params.presenter.chatInputManager.syncSnapshot({
84
+ isProviderStateResolved: params.isProviderStateResolved,
85
+ defaultSessionType: params.defaultSessionType,
86
+ canStopGeneration: params.canStopCurrentRun,
87
+ stopDisabledReason: params.stopDisabledReason,
88
+ stopSupported: true,
89
+ stopReason: undefined,
90
+ sendError: params.lastSendError,
91
+ isSending: params.isSending,
92
+ modelOptions: params.modelOptions,
93
+ sessionTypeOptions: params.sessionTypeOptions,
94
+ selectedSessionType: params.selectedSessionType,
95
+ canEditSessionType: params.canEditSessionType,
96
+ sessionTypeUnavailable: params.sessionTypeUnavailable,
97
+ skillRecords: params.skillRecords,
98
+ isSkillsLoading: params.isSkillsLoading,
99
+ });
100
+ params.presenter.chatThreadManager.syncSnapshot({
101
+ isProviderStateResolved: params.isProviderStateResolved,
102
+ modelOptions: params.modelOptions,
103
+ sessionTypeUnavailable: params.sessionTypeUnavailable,
104
+ sessionTypeUnavailableMessage: params.sessionTypeUnavailableMessage,
105
+ sessionTypeLabel: params.currentSessionTypeLabel,
106
+ sessionKey: params.sessionKey,
107
+ agentId: params.currentAgentId,
108
+ agentDisplayName: params.currentAgent?.displayName ?? null,
109
+ agentAvatarUrl: params.currentAgent?.avatarUrl ?? null,
110
+ availableAgents: params.availableAgents,
111
+ sessionDisplayName: params.currentSessionDisplayName,
112
+ sessionProjectRoot: params.effectiveSessionProjectRoot,
113
+ sessionProjectName: params.effectiveSessionProjectName,
114
+ canDeleteSession: Boolean(params.selectedSession),
115
+ threadRef: params.threadRef,
116
+ isHistoryLoading: params.agent.isHydrating,
117
+ messages: params.agent.visibleMessages,
118
+ isSending: params.isSending,
119
+ isAwaitingAssistantOutput: params.isAwaitingAssistantOutput,
120
+ parentSessionKey: params.parentSession?.key ?? null,
121
+ parentSessionLabel: params.parentSession
122
+ ? sessionDisplayName(params.parentSession)
123
+ : null,
124
+ });
125
+ }, [
126
+ params
127
+ ]);
128
+ }
@@ -0,0 +1,52 @@
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';
7
+
8
+ export type ResolvedChildSessionTab = {
9
+ sessionKey: string;
10
+ parentSessionKey: string | null;
11
+ title: string;
12
+ agentId: string | null;
13
+ };
14
+
15
+ function resolveChildSessionTitle(
16
+ tab: ChatChildSessionTab,
17
+ session: SessionEntryView | null,
18
+ ): string {
19
+ if (tab.label?.trim()) {
20
+ return tab.label.trim();
21
+ }
22
+ if (session) {
23
+ return sessionDisplayName(session);
24
+ }
25
+ return tab.sessionKey;
26
+ }
27
+
28
+ export function useNcpChildSessionTabsView(
29
+ tabs: readonly ChatChildSessionTab[],
30
+ ): ResolvedChildSessionTab[] {
31
+ const sessionsQuery = useNcpSessions({ limit: 200 });
32
+
33
+ const sessionByKey = useMemo(() => {
34
+ const sessions = adaptNcpSessionSummaries(sessionsQuery.data?.sessions ?? []);
35
+ return new Map(sessions.map((session) => [session.key, session]));
36
+ }, [sessionsQuery.data?.sessions]);
37
+
38
+ return useMemo(
39
+ () =>
40
+ tabs.map((tab) => {
41
+ const session = sessionByKey.get(tab.sessionKey) ?? null;
42
+ const agentId = tab.agentId?.trim() || session?.agentId || null;
43
+ return {
44
+ sessionKey: tab.sessionKey,
45
+ parentSessionKey: tab.parentSessionKey,
46
+ title: resolveChildSessionTitle(tab, session),
47
+ agentId,
48
+ };
49
+ }),
50
+ [sessionByKey, tabs],
51
+ );
52
+ }