@nextclaw/ui 0.11.23 → 0.12.0

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 (117) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-NKNKsf1J.js} +1 -1
  3. package/dist/assets/ChatPage-p23OnnEI.js +43 -0
  4. package/dist/assets/DocBrowser-C8b2uPgL.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DxdSujSc.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CQ-8jMha.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D-KQIN4U.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-CRNvxtvx.js} +2 -2
  9. package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
  11. package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-CEpx9fro.js} +1 -1
  12. package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-BWbUb7-2.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-NsawrZb0.js} +1 -1
  14. package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
  15. package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-BsaX_WYy.js} +1 -1
  16. package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-CgDZOd3w.js} +1 -1
  17. package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-Dd-KM7F7.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-FnK2xCQd.js} +1 -1
  19. package/dist/assets/chat-session-display-BD_AN71I.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-B5l0hr_u.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-JKmXfZ3q.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-o1WWhwhd.js} +1 -1
  23. package/dist/assets/{dist-nqTTbVdA.js → dist-C_moWYv7.js} +1 -1
  24. package/dist/assets/{dist-Cl2QB-2y.js → dist-DazA6Wd_.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BKje3SiD.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-DfW4DT8O.js} +1 -1
  27. package/dist/assets/i18n-BK1w-oBy.js +1 -0
  28. package/dist/assets/index-BZaB1TqM.js +6 -0
  29. package/dist/assets/index-DaR9igPC.css +1 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-BzDWmdOe.js} +1 -1
  31. package/dist/assets/loader-circle-DdZPxBUz.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CTLlde_T.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-BagR3t59.js} +1 -1
  34. package/dist/assets/plus-DP2PSCPO.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-5DWhNfd4.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-DJ29qHuA.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-C3yu5yge.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BAJf-h7w.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-aa6z4GJL.js} +1 -1
  40. package/dist/assets/search-pD6ZwQYF.js +1 -0
  41. package/dist/assets/{security-config-DbUyWcQz.js → security-config-DRDxrApx.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-BHJPiJWt.js} +1 -1
  43. package/dist/assets/skeleton-D6kCk9Y6.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-DUwsTIdv.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B6nCfcOB.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-B57SMElx.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-CrjYH5ok.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-DsxnXB1B.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-oTTWXgLG.js} +1 -1
  50. package/dist/assets/x-CTIQHUuD.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +23 -2
  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 +141 -13
  59. package/src/components/chat/ChatConversationPanel.tsx +29 -7
  60. package/src/components/chat/ChatSidebar.test.tsx +8 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +25 -0
  63. package/src/components/chat/ChatWelcome.tsx +47 -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-shell.tsx +8 -17
  71. package/src/components/chat/chat-session-route.ts +0 -14
  72. package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
  73. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  74. package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
  75. package/src/components/chat/ncp/NcpChatPage.tsx +58 -160
  76. package/src/components/chat/ncp/README.md +3 -0
  77. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  78. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
  79. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  80. package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
  81. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  82. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  83. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  85. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  86. package/src/components/chat/stores/chat-thread.store.ts +20 -6
  87. package/src/components/common/AgentAvatar.tsx +63 -0
  88. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  89. package/src/components/common/agent-identity/index.ts +3 -0
  90. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  91. package/src/components/config/RuntimeConfig.tsx +13 -79
  92. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  93. package/src/components/layout/AppLayout.tsx +3 -1
  94. package/src/components/layout/Sidebar.tsx +6 -1
  95. package/src/components/layout/app-layout.test.tsx +30 -0
  96. package/src/components/ui/tabs.tsx +2 -0
  97. package/src/hooks/README.md +3 -0
  98. package/src/hooks/agents/useAgents.ts +44 -0
  99. package/src/lib/i18n.agents.ts +66 -0
  100. package/src/lib/i18n.chat.ts +5 -0
  101. package/src/lib/i18n.ts +4 -4
  102. package/src/lib/ui-document-title.ts +1 -0
  103. package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
  104. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  105. package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
  106. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
  107. package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
  108. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  109. package/dist/assets/index-BuwbBgmT.js +0 -6
  110. package/dist/assets/index-bZ8cqQIS.css +0 -1
  111. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  112. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  113. package/dist/assets/search-C91yH_6y.js +0 -1
  114. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  115. package/dist/assets/x-D7Q1yqSF.js +0 -1
  116. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  117. /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,51 +78,6 @@ 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],
131
- );
132
- }
133
-
134
81
  export function NcpChatPage({ view }: ChatPageProps) {
135
82
  const [presenter] = useState(() => new NcpChatPresenter());
136
83
  const [draftSessionId, setDraftSessionId] = useState(() =>
@@ -152,6 +99,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
152
99
  const pendingProjectRootSessionKey = useChatInputStore(
153
100
  (state) => state.snapshot.pendingProjectRootSessionKey,
154
101
  );
102
+ const agentsQuery = useAgents();
155
103
  const currentSelectedModel = useChatInputStore(
156
104
  (state) => state.snapshot.selectedModel,
157
105
  );
@@ -198,27 +146,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
198
146
  presenter.chatInputManager.setSelectedThinkingLevel,
199
147
  });
200
148
 
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
- });
149
+ const agent = useNcpSessionConversation(sessionKey);
222
150
 
223
151
  useEffect(() => {
224
152
  presenter.setDraftSessionId(draftSessionId);
@@ -255,6 +183,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
255
183
  return;
256
184
  }
257
185
  const metadata = buildNcpSendMetadata({
186
+ agentId: payload.agentId,
258
187
  model: payload.model,
259
188
  thinkingLevel: payload.thinkingLevel,
260
189
  sessionType: payload.sessionType,
@@ -336,13 +265,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
336
265
  view,
337
266
  routeSessionKey,
338
267
  selectedSessionKey,
339
- selectedAgentId,
340
268
  setSelectedSessionKey:
341
269
  presenter.chatSessionListManager.setSelectedSessionKey,
342
- setSelectedAgentId: presenter.chatSessionListManager.setSelectedAgentId,
343
270
  selectedSessionKeyRef,
344
271
  resetStreamState: presenter.chatStreamActionsManager.resetStreamState,
345
- resolveAgentIdFromSessionKey,
346
272
  });
347
273
 
348
274
  useEffect(() => {
@@ -355,90 +281,62 @@ export function NcpChatPage({ view }: ChatPageProps) {
355
281
  });
356
282
  }, [confirm, location.pathname, navigate, presenter]);
357
283
 
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);
284
+ const availableAgents = (agentsQuery.data?.agents?.length ?? 0) > 0
285
+ ? (agentsQuery.data?.agents ?? [])
286
+ : [{ id: selectedSession?.agentId ?? selectedAgentId }];
287
+ const {
288
+ currentSessionDisplayName,
289
+ currentAgentId,
290
+ currentAgent,
291
+ parentSession,
292
+ currentSessionTypeLabel
293
+ } = useNcpChatDerivedState({
294
+ selectedSession,
295
+ selectedAgentId,
296
+ availableAgents,
297
+ parentSessionId,
298
+ sessionSummaries,
299
+ selectedSessionType,
300
+ sessionTypeOptions
301
+ });
374
302
 
375
303
  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,
304
+ if (!selectedSession?.agentId || selectedAgentId === selectedSession.agentId) {
305
+ return;
306
+ }
307
+ presenter.chatSessionListManager.setSelectedAgentId(selectedSession.agentId);
308
+ }, [presenter, selectedAgentId, selectedSession?.agentId]);
309
+
310
+ useNcpChatSnapshotSync({
311
+ presenter,
423
312
  isProviderStateResolved,
424
- isSending,
313
+ defaultSessionType,
314
+ canStopCurrentRun,
315
+ stopDisabledReason,
425
316
  lastSendError,
317
+ isSending,
426
318
  modelOptions,
427
- parentSession,
428
- presenter,
429
- effectiveSessionProjectName,
430
- effectiveSessionProjectRoot,
431
- selectedSession,
432
- sessionKey,
433
- selectedSessionType,
434
319
  sessionTypeOptions,
320
+ selectedSessionType,
321
+ canEditSessionType,
435
322
  sessionTypeUnavailable,
436
- sessionTypeUnavailableMessage,
437
323
  skillRecords,
438
- stopDisabledReason,
324
+ isSkillsLoading: sessionSkillsQuery.isLoading,
325
+ sessionTypeUnavailableMessage,
326
+ currentSessionTypeLabel,
327
+ sessionKey,
328
+ currentAgentId,
329
+ currentAgent,
330
+ availableAgents,
331
+ currentSessionDisplayName,
332
+ effectiveSessionProjectRoot,
333
+ effectiveSessionProjectName,
334
+ selectedSession,
439
335
  threadRef,
440
- agent.visibleMessages,
441
- ]);
336
+ agent,
337
+ isAwaitingAssistantOutput,
338
+ parentSession
339
+ });
442
340
 
443
341
  return (
444
342
  <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
+ }