@nextclaw/ui 0.11.22 → 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 (122) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{ChannelsList-Zeys_w43.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-Cd4faegU.js → MarketplacePage-CRNvxtvx.js} +2 -2
  9. package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-C09Ngs7O.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
  11. package/dist/assets/{ModelConfig-DJgdcgvQ.js → ModelConfig-CEpx9fro.js} +1 -1
  12. package/dist/assets/{ProvidersList-w0rVFIBf.js → ProvidersList-BWbUb7-2.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-BJ_ckkOV.js → RemoteAccessPage-NsawrZb0.js} +1 -1
  14. package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
  15. package/dist/assets/{SearchConfig-BT13qpR_.js → SearchConfig-BsaX_WYy.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CvqEVn0B.js → SecretsConfig-CgDZOd3w.js} +1 -1
  17. package/dist/assets/{SessionsConfig-DHHcYznk.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-T5zpg16O.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-Bs5Ll17m.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 +5 -5
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +27 -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 +144 -8
  59. package/src/components/chat/ChatConversationPanel.tsx +136 -77
  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 +18 -5
  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 +89 -9
  68. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +200 -0
  69. package/src/components/chat/chat-child-session-panel.tsx +166 -0
  70. package/src/components/chat/chat-page-runtime.test.ts +1 -0
  71. package/src/components/chat/chat-page-shell.tsx +8 -17
  72. package/src/components/chat/chat-session-display.test.ts +1 -0
  73. package/src/components/chat/chat-session-route.ts +0 -14
  74. package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
  75. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  76. package/src/components/chat/containers/chat-message-list.container.tsx +11 -0
  77. package/src/components/chat/ncp/NcpChatPage.tsx +153 -190
  78. package/src/components/chat/ncp/README.md +3 -0
  79. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  80. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +106 -1
  81. package/src/components/chat/ncp/ncp-session-adapter.test.ts +23 -0
  82. package/src/components/chat/ncp/ncp-session-adapter.ts +32 -0
  83. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  85. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  86. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  87. package/src/components/chat/ncp/use-ncp-session-list-view.ts +10 -1
  88. package/src/components/chat/presenter/chat-presenter-context.tsx +5 -1
  89. package/src/components/chat/stores/chat-thread.store.ts +25 -1
  90. package/src/components/chat/useHydratedNcpAgent.test.tsx +30 -23
  91. package/src/components/common/AgentAvatar.tsx +63 -0
  92. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  93. package/src/components/common/agent-identity/index.ts +3 -0
  94. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  95. package/src/components/config/RuntimeConfig.tsx +13 -79
  96. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  97. package/src/components/layout/AppLayout.tsx +3 -1
  98. package/src/components/layout/Sidebar.tsx +6 -1
  99. package/src/components/layout/app-layout.test.tsx +30 -0
  100. package/src/components/ui/tabs.tsx +2 -0
  101. package/src/hooks/README.md +3 -0
  102. package/src/hooks/agents/useAgents.ts +44 -0
  103. package/src/lib/i18n.agents.ts +66 -0
  104. package/src/lib/i18n.chat.ts +5 -0
  105. package/src/lib/i18n.ts +4 -4
  106. package/src/lib/ui-document-title.ts +1 -0
  107. package/dist/assets/ChatPage-DWOU_8P6.js +0 -43
  108. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  109. package/dist/assets/MarketplacePage-BfaTTqN6.js +0 -1
  110. package/dist/assets/RuntimeConfig-Cmn2xPQO.js +0 -1
  111. package/dist/assets/chat-session-display-VW6ZMvZP.js +0 -1
  112. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  113. package/dist/assets/index-BlH4-cBw.css +0 -1
  114. package/dist/assets/index-C6d0xmtm.js +0 -6
  115. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  116. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  117. package/dist/assets/search-C91yH_6y.js +0 -1
  118. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  119. package/dist/assets/x-D7Q1yqSF.js +0 -1
  120. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +0 -154
  121. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  122. /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import type { NcpMessage } from "@nextclaw/ncp";
3
3
  import {
4
+ type ChatToolActionViewModel,
4
5
  type ChatMessageViewModel,
5
6
  ChatMessageList,
6
7
  } from "@nextclaw/agent-chat-ui";
@@ -11,6 +12,7 @@ import {
11
12
  } from "@/components/chat/adapters/chat-message.adapter";
12
13
  import { readInlineTokensFromMetadata } from "@/components/chat/chat-inline-token.utils";
13
14
  import { adaptNcpMessageToUiMessage } from "@/components/chat/ncp/ncp-session-adapter";
15
+ import { AgentIdentityAvatar } from "@/components/common/agent-identity";
14
16
  import { useI18n } from "@/components/providers/I18nProvider";
15
17
  import { formatDateTime, t } from "@/lib/i18n";
16
18
 
@@ -18,6 +20,7 @@ type ChatMessageListContainerProps = {
18
20
  messages: readonly NcpMessage[];
19
21
  isSending: boolean;
20
22
  className?: string;
23
+ onToolAction?: (action: ChatToolActionViewModel) => void;
21
24
  };
22
25
 
23
26
  const messageViewModelCache = new WeakMap<
@@ -69,6 +72,7 @@ export function ChatMessageListContainer({
69
72
  messages: rawMessages,
70
73
  isSending,
71
74
  className,
75
+ onToolAction,
72
76
  }: ChatMessageListContainerProps) {
73
77
  const { language } = useI18n();
74
78
  const texts = useMemo<ChatMessageAdapterTexts>(
@@ -125,6 +129,13 @@ export function ChatMessageListContainer({
125
129
  hasAssistantDraft={hasAssistantDraft}
126
130
  className={className}
127
131
  texts={messageTexts}
132
+ onToolAction={onToolAction}
133
+ renderToolAgent={(agentId) => (
134
+ <AgentIdentityAvatar
135
+ agentId={agentId}
136
+ className="h-4 w-4 shrink-0"
137
+ />
138
+ )}
128
139
  />
129
140
  );
130
141
  }
@@ -1,32 +1,44 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { NcpHttpAgentClientEndpoint } from '@nextclaw/ncp-http-agent-client';
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ } from "react";
3
7
  import {
4
8
  buildNcpRequestEnvelope,
5
- useHydratedNcpAgent,
6
- type NcpConversationSeed
7
- } from '@nextclaw/ncp-react';
8
- import { useLocation, useNavigate, useParams } from 'react-router-dom';
9
- import { API_BASE } from '@/api/api-base';
10
- import { fetchNcpSessionMessages } from '@/api/ncp-session';
11
- import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
12
- import { sessionDisplayName } from '@/components/chat/chat-session-display';
13
- import { buildInlineSkillTokensFromComposer, CHAT_UI_INLINE_TOKENS_METADATA_KEY } from '@/components/chat/chat-inline-token.utils';
14
- import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
15
- import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
16
- import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
17
- import { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
18
- import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
19
- import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
20
- import type { ResumeRunParams } from '@/components/chat/chat-stream/types';
21
- import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
22
- import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
23
- import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
24
- import { useConfirmDialog } from '@/hooks/useConfirmDialog';
25
- import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
26
- import { getSessionProjectName, normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
27
- import { appClient } from '@/transport';
9
+ } from "@nextclaw/ncp-react";
10
+ import { useLocation, useNavigate, useParams } from "react-router-dom";
11
+ import {
12
+ ChatPageLayout,
13
+ type ChatPageProps,
14
+ useChatSessionSync,
15
+ } from "@/components/chat/chat-page-shell";
16
+ import {
17
+ buildInlineSkillTokensFromComposer,
18
+ CHAT_UI_INLINE_TOKENS_METADATA_KEY,
19
+ } from "@/components/chat/chat-inline-token.utils";
20
+ import {
21
+ parseSessionKeyFromRoute,
22
+ } from "@/components/chat/chat-session-route";
23
+ import { useNcpChatPageData } from "@/components/chat/ncp/ncp-chat-page-data";
24
+ import { NcpChatPresenter } from "@/components/chat/ncp/ncp-chat.presenter";
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";
28
+ import { ChatPresenterProvider } from "@/components/chat/presenter/chat-presenter-context";
29
+ import type { ResumeRunParams } from "@/components/chat/chat-stream/types";
30
+ import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
31
+ import { useChatSessionListStore } from "@/components/chat/stores/chat-session-list.store";
32
+ import { useConfirmDialog } from "@/hooks/useConfirmDialog";
33
+ import { useAgents } from "@/hooks/agents/useAgents";
34
+ import { normalizeRequestedSkills } from "@/lib/chat-runtime-utils";
35
+ import {
36
+ getSessionProjectName,
37
+ normalizeSessionProjectRootValue,
38
+ } from "@/lib/session-project/session-project.utils";
28
39
 
29
40
  export function buildNcpSendMetadata(payload: {
41
+ agentId?: string;
30
42
  model?: string;
31
43
  thinkingLevel?: string;
32
44
  sessionType?: string;
@@ -46,6 +58,9 @@ export function buildNcpSendMetadata(payload: {
46
58
  if (payload.sessionType?.trim()) {
47
59
  metadata.session_type = payload.sessionType.trim();
48
60
  }
61
+ if (payload.agentId?.trim()) {
62
+ metadata.agent_id = payload.agentId.trim();
63
+ }
49
64
  const projectRoot = normalizeSessionProjectRootValue(payload.projectRoot);
50
65
  if (projectRoot) {
51
66
  metadata.project_root = projectRoot;
@@ -63,37 +78,49 @@ export function buildNcpSendMetadata(payload: {
63
78
  return metadata;
64
79
  }
65
80
 
66
- function isMissingNcpSessionError(error: unknown): boolean {
67
- if (!(error instanceof Error)) {
68
- return false;
69
- }
70
- return error.message.includes('ncp session not found:');
71
- }
72
-
73
81
  export function NcpChatPage({ view }: ChatPageProps) {
74
82
  const [presenter] = useState(() => new NcpChatPresenter());
75
- const [draftSessionId, setDraftSessionId] = useState(() => createNcpSessionId());
83
+ const [draftSessionId, setDraftSessionId] = useState(() =>
84
+ createNcpSessionId(),
85
+ );
76
86
  const query = useChatSessionListStore((state) => state.snapshot.query);
77
- const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
78
- const selectedAgentId = useChatSessionListStore((state) => state.snapshot.selectedAgentId);
79
- const pendingSessionType = useChatInputStore((state) => state.snapshot.pendingSessionType);
80
- const pendingProjectRoot = useChatInputStore((state) => state.snapshot.pendingProjectRoot);
81
- const pendingProjectRootSessionKey = useChatInputStore((state) => state.snapshot.pendingProjectRootSessionKey);
82
- const currentSelectedModel = useChatInputStore((state) => state.snapshot.selectedModel);
87
+ const selectedSessionKey = useChatSessionListStore(
88
+ (state) => state.snapshot.selectedSessionKey,
89
+ );
90
+ const selectedAgentId = useChatSessionListStore(
91
+ (state) => state.snapshot.selectedAgentId,
92
+ );
93
+ const pendingSessionType = useChatInputStore(
94
+ (state) => state.snapshot.pendingSessionType,
95
+ );
96
+ const pendingProjectRoot = useChatInputStore(
97
+ (state) => state.snapshot.pendingProjectRoot,
98
+ );
99
+ const pendingProjectRootSessionKey = useChatInputStore(
100
+ (state) => state.snapshot.pendingProjectRootSessionKey,
101
+ );
102
+ const agentsQuery = useAgents();
103
+ const currentSelectedModel = useChatInputStore(
104
+ (state) => state.snapshot.selectedModel,
105
+ );
83
106
  const { confirm, ConfirmDialog } = useConfirmDialog();
84
107
  const location = useLocation();
85
108
  const navigate = useNavigate();
86
- const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
109
+ const { sessionId: routeSessionIdParam } = useParams<{
110
+ sessionId?: string;
111
+ }>();
87
112
  const threadRef = useRef<HTMLDivElement | null>(null);
88
113
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
89
- const sessionStreamAttachInFlightRef = useRef(false);
90
114
  const routeSessionKey = useMemo(
91
115
  () => parseSessionKeyFromRoute(routeSessionIdParam),
92
- [routeSessionIdParam]
116
+ [routeSessionIdParam],
93
117
  );
94
118
  const sessionKey = selectedSessionKey ?? draftSessionId;
95
- const hasSessionProjectRootOverride = pendingProjectRootSessionKey === sessionKey;
96
- const sessionProjectRootOverride = hasSessionProjectRootOverride ? pendingProjectRoot : undefined;
119
+ const hasSessionProjectRootOverride =
120
+ pendingProjectRootSessionKey === sessionKey;
121
+ const sessionProjectRootOverride = hasSessionProjectRootOverride
122
+ ? pendingProjectRoot
123
+ : undefined;
97
124
  const {
98
125
  sessionSkillsQuery,
99
126
  isProviderStateResolved,
@@ -106,7 +133,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
106
133
  selectedSessionType,
107
134
  canEditSessionType,
108
135
  sessionTypeUnavailable,
109
- sessionTypeUnavailableMessage
136
+ sessionTypeUnavailableMessage,
110
137
  } = useNcpChatPageData({
111
138
  query,
112
139
  sessionKey,
@@ -115,47 +142,11 @@ export function NcpChatPage({ view }: ChatPageProps) {
115
142
  pendingSessionType,
116
143
  setPendingSessionType: presenter.chatInputManager.setPendingSessionType,
117
144
  setSelectedModel: presenter.chatInputManager.setSelectedModel,
118
- setSelectedThinkingLevel: presenter.chatInputManager.setSelectedThinkingLevel
145
+ setSelectedThinkingLevel:
146
+ presenter.chatInputManager.setSelectedThinkingLevel,
119
147
  });
120
148
 
121
- const sessionSummariesRef = useRef(sessionSummaries);
122
- useEffect(() => {
123
- sessionSummariesRef.current = sessionSummaries;
124
- }, [sessionSummaries]);
125
-
126
- const [ncpClient] = useState(
127
- () =>
128
- new NcpHttpAgentClientEndpoint({
129
- baseUrl: API_BASE,
130
- basePath: '/api/ncp/agent',
131
- fetchImpl: createNcpAppClientFetch()
132
- })
133
- );
134
-
135
- const loadSeed = useCallback(async (sessionId: string, signal: AbortSignal): Promise<NcpConversationSeed> => {
136
- signal.throwIfAborted();
137
- let history: Awaited<ReturnType<typeof fetchNcpSessionMessages>> | null = null;
138
- try {
139
- history = await fetchNcpSessionMessages(sessionId, 300);
140
- } catch (error) {
141
- if (!isMissingNcpSessionError(error)) {
142
- throw error;
143
- }
144
- }
145
- signal.throwIfAborted();
146
-
147
- const sessionSummary = sessionSummariesRef.current.find((item) => item.sessionId === sessionId) ?? null;
148
- return {
149
- messages: history?.messages ?? [],
150
- status: sessionSummary?.status === 'running' ? 'running' : 'idle'
151
- };
152
- }, []);
153
-
154
- const agent = useHydratedNcpAgent({
155
- sessionId: sessionKey,
156
- client: ncpClient,
157
- loadSeed
158
- });
149
+ const agent = useNcpSessionConversation(sessionKey);
159
150
 
160
151
  useEffect(() => {
161
152
  presenter.setDraftSessionId(draftSessionId);
@@ -169,43 +160,21 @@ export function NcpChatPage({ view }: ChatPageProps) {
169
160
  }
170
161
  }, [presenter, selectedSessionKey]);
171
162
 
172
- const effectiveSessionProjectRoot = hasSessionProjectRootOverride ? pendingProjectRoot : selectedSession?.projectRoot ?? null;
173
- const effectiveSessionProjectName = hasSessionProjectRootOverride ? getSessionProjectName(effectiveSessionProjectRoot) : selectedSession?.projectName ?? getSessionProjectName(effectiveSessionProjectRoot);
163
+ const effectiveSessionProjectRoot = hasSessionProjectRootOverride
164
+ ? pendingProjectRoot
165
+ : (selectedSession?.projectRoot ?? null);
166
+ const effectiveSessionProjectName = hasSessionProjectRootOverride
167
+ ? getSessionProjectName(effectiveSessionProjectRoot)
168
+ : (selectedSession?.projectName ??
169
+ getSessionProjectName(effectiveSessionProjectRoot));
170
+ const parentSessionId = selectedSession?.parentSessionId ?? null;
174
171
 
175
172
  const isSending = agent.isSending || agent.isRunning;
176
173
  const isAwaitingAssistantOutput = agent.isRunning;
177
174
  const canStopCurrentRun = agent.isRunning;
178
- const stopDisabledReason = agent.isRunning ? null : '__preparing__';
179
- const lastSendError = agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
180
-
181
- useEffect(() => {
182
- const attachRealtimeSessionStream = () => {
183
- if (sessionStreamAttachInFlightRef.current) {
184
- return;
185
- }
186
- if (agent.isHydrating || agent.isRunning || agent.isSending) {
187
- return;
188
- }
189
-
190
- sessionStreamAttachInFlightRef.current = true;
191
- void ncpClient
192
- .stream({ sessionId: sessionKey })
193
- .catch(() => undefined)
194
- .finally(() => {
195
- sessionStreamAttachInFlightRef.current = false;
196
- });
197
- };
198
-
199
- return appClient.subscribe((event) => {
200
- if (
201
- event.type === 'session.run-status' &&
202
- event.payload.sessionKey === sessionKey &&
203
- event.payload.status === 'running'
204
- ) {
205
- attachRealtimeSessionStream();
206
- }
207
- });
208
- }, [agent.isHydrating, agent.isRunning, agent.isSending, ncpClient, sessionKey]);
175
+ const stopDisabledReason = agent.isRunning ? null : "__preparing__";
176
+ const lastSendError =
177
+ agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
209
178
 
210
179
  useEffect(() => {
211
180
  presenter.chatStreamActionsManager.bind({
@@ -214,22 +183,23 @@ export function NcpChatPage({ view }: ChatPageProps) {
214
183
  return;
215
184
  }
216
185
  const metadata = buildNcpSendMetadata({
186
+ agentId: payload.agentId,
217
187
  model: payload.model,
218
188
  thinkingLevel: payload.thinkingLevel,
219
189
  sessionType: payload.sessionType,
220
190
  projectRoot:
221
191
  payload.sessionKey === pendingProjectRootSessionKey
222
192
  ? pendingProjectRoot
223
- : selectedSession?.projectRoot ?? null,
193
+ : (selectedSession?.projectRoot ?? null),
224
194
  requestedSkills: payload.requestedSkills,
225
- composerNodes: payload.composerNodes
195
+ composerNodes: payload.composerNodes,
226
196
  });
227
197
  const envelope = buildNcpRequestEnvelope({
228
198
  sessionId: payload.sessionKey,
229
199
  text: payload.message,
230
200
  attachments: payload.attachments,
231
201
  parts: payload.parts,
232
- metadata
202
+ metadata,
233
203
  });
234
204
  if (!envelope) {
235
205
  return;
@@ -241,11 +211,13 @@ export function NcpChatPage({ view }: ChatPageProps) {
241
211
  if (payload.composerNodes && payload.composerNodes.length > 0) {
242
212
  presenter.chatInputManager.restoreComposerState?.(
243
213
  payload.composerNodes,
244
- payload.attachments ?? []
214
+ payload.attachments ?? [],
245
215
  );
246
216
  } else {
247
217
  presenter.chatInputManager.setDraft((currentDraft) =>
248
- currentDraft.trim().length === 0 ? payload.message : currentDraft
218
+ currentDraft.trim().length === 0
219
+ ? payload.message
220
+ : currentDraft,
249
221
  );
250
222
  }
251
223
  }
@@ -264,7 +236,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
264
236
  resetStreamState: () => {
265
237
  selectedSessionKeyRef.current = null;
266
238
  },
267
- applyHistoryMessages: () => {}
239
+ applyHistoryMessages: () => {},
268
240
  });
269
241
  }, [
270
242
  agent,
@@ -272,16 +244,20 @@ export function NcpChatPage({ view }: ChatPageProps) {
272
244
  pendingProjectRootSessionKey,
273
245
  presenter,
274
246
  selectedSession?.projectRoot,
275
- sessionKey
247
+ sessionKey,
276
248
  ]);
277
249
 
278
250
  useEffect(() => {
279
- if (!selectedSession || pendingProjectRootSessionKey !== selectedSession.key || (selectedSession.projectRoot ?? null) !== pendingProjectRoot) {
251
+ if (
252
+ !selectedSession ||
253
+ pendingProjectRootSessionKey !== selectedSession.key ||
254
+ (selectedSession.projectRoot ?? null) !== pendingProjectRoot
255
+ ) {
280
256
  return;
281
257
  }
282
258
  useChatInputStore.getState().setSnapshot({
283
259
  pendingProjectRoot: null,
284
- pendingProjectRootSessionKey: null
260
+ pendingProjectRootSessionKey: null,
285
261
  });
286
262
  }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession]);
287
263
 
@@ -289,91 +265,78 @@ export function NcpChatPage({ view }: ChatPageProps) {
289
265
  view,
290
266
  routeSessionKey,
291
267
  selectedSessionKey,
292
- selectedAgentId,
293
- setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
294
- setSelectedAgentId: presenter.chatSessionListManager.setSelectedAgentId,
268
+ setSelectedSessionKey:
269
+ presenter.chatSessionListManager.setSelectedSessionKey,
295
270
  selectedSessionKeyRef,
296
271
  resetStreamState: presenter.chatStreamActionsManager.resetStreamState,
297
- resolveAgentIdFromSessionKey
298
272
  });
299
273
 
300
274
  useEffect(() => {
301
275
  presenter.chatUiManager.syncState({
302
- pathname: location.pathname
276
+ pathname: location.pathname,
303
277
  });
304
278
  presenter.chatUiManager.bindActions({
305
279
  navigate,
306
- confirm
280
+ confirm,
307
281
  });
308
282
  }, [confirm, location.pathname, navigate, presenter]);
309
283
 
310
- const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
311
- const currentSessionTypeLabel =
312
- sessionTypeOptions.find((option) => option.value === selectedSessionType)?.label ??
313
- 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
+ });
314
302
 
315
303
  useEffect(() => {
316
- presenter.chatInputManager.syncSnapshot({
317
- isProviderStateResolved,
318
- defaultSessionType,
319
- canStopGeneration: canStopCurrentRun,
320
- stopDisabledReason,
321
- stopSupported: true,
322
- stopReason: undefined,
323
- sendError: lastSendError,
324
- isSending,
325
- modelOptions,
326
- sessionTypeOptions,
327
- selectedSessionType,
328
- canEditSessionType,
329
- sessionTypeUnavailable,
330
- skillRecords,
331
- isSkillsLoading: sessionSkillsQuery.isLoading
332
- });
333
- presenter.chatThreadManager.syncSnapshot({
334
- isProviderStateResolved,
335
- modelOptions,
336
- sessionTypeUnavailable,
337
- sessionTypeUnavailableMessage,
338
- sessionTypeLabel: currentSessionTypeLabel,
339
- sessionKey,
340
- sessionDisplayName: currentSessionDisplayName,
341
- sessionProjectRoot: effectiveSessionProjectRoot,
342
- sessionProjectName: effectiveSessionProjectName,
343
- canDeleteSession: Boolean(selectedSession),
344
- threadRef,
345
- isHistoryLoading: agent.isHydrating,
346
- messages: agent.visibleMessages,
347
- isSending,
348
- isAwaitingAssistantOutput
349
- });
350
- }, [
351
- agent.isHydrating,
352
- canEditSessionType,
353
- canStopCurrentRun,
354
- currentSessionDisplayName,
355
- currentSessionTypeLabel,
356
- defaultSessionType,
357
- sessionSkillsQuery.isLoading,
358
- 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,
359
312
  isProviderStateResolved,
360
- isSending,
313
+ defaultSessionType,
314
+ canStopCurrentRun,
315
+ stopDisabledReason,
361
316
  lastSendError,
317
+ isSending,
362
318
  modelOptions,
363
- presenter,
364
- effectiveSessionProjectName,
365
- effectiveSessionProjectRoot,
366
- selectedSession,
367
- sessionKey,
368
- selectedSessionType,
369
319
  sessionTypeOptions,
320
+ selectedSessionType,
321
+ canEditSessionType,
370
322
  sessionTypeUnavailable,
371
- sessionTypeUnavailableMessage,
372
323
  skillRecords,
373
- 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,
374
335
  threadRef,
375
- agent.visibleMessages
376
- ]);
336
+ agent,
337
+ isAwaitingAssistantOutput,
338
+ parentSession
339
+ });
377
340
 
378
341
  return (
379
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
  });
@@ -1,11 +1,15 @@
1
1
  import { appQueryClient } from '@/app-query-client';
2
2
  import { deleteNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
3
3
  import { deleteNcpSession as deleteNcpSessionApi } from '@/api/ncp-session';
4
+ import type { ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
4
5
  import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
5
6
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
6
7
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
7
8
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
8
- 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';
9
13
  import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
10
14
  import { t } from '@/lib/i18n';
11
15
 
@@ -45,6 +49,107 @@ export class NcpChatThreadManager {
45
49
  this.uiManager.goToProviders();
46
50
  };
47
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
+
69
+ openSessionFromToolAction = (action: ChatToolActionViewModel) => {
70
+ if (action.kind !== 'open-session') {
71
+ return;
72
+ }
73
+ if (action.sessionKind === 'child' && !this.isCompactViewport()) {
74
+ const parentSessionKey =
75
+ action.parentSessionId?.trim() ||
76
+ useChatSessionListStore.getState().snapshot.selectedSessionKey ||
77
+ null;
78
+ this.upsertChildSessionTab({
79
+ sessionKey: action.sessionId,
80
+ parentSessionKey,
81
+ label: action.label?.trim() || null,
82
+ agentId: action.agentId?.trim() || null,
83
+ });
84
+ return;
85
+ }
86
+ this.uiManager.goToSession(action.sessionId);
87
+ };
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
+
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;
122
+ useChatThreadStore.getState().setSnapshot({
123
+ childSessionTabs: nextTabs,
124
+ activeChildSessionKey: nextActiveKey,
125
+ });
126
+ };
127
+
128
+ goToParentSession = () => {
129
+ const {
130
+ parentSessionKey,
131
+ childSessionTabs,
132
+ activeChildSessionKey,
133
+ } = useChatThreadStore.getState().snapshot;
134
+ const activeChildParentSessionKey =
135
+ childSessionTabs.find((tab) => tab.sessionKey === activeChildSessionKey)
136
+ ?.parentSessionKey ?? null;
137
+ const resolvedParentSessionKey =
138
+ parentSessionKey ?? activeChildParentSessionKey;
139
+ if (!resolvedParentSessionKey) {
140
+ return;
141
+ }
142
+ this.closeChildSessionDetail();
143
+ this.uiManager.goToSession(resolvedParentSessionKey);
144
+ };
145
+
146
+ private isCompactViewport = (): boolean => {
147
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
148
+ return false;
149
+ }
150
+ return window.matchMedia('(max-width: 767px)').matches;
151
+ };
152
+
48
153
  private deleteCurrentSession = async () => {
49
154
  const {
50
155
  snapshot: { selectedSessionKey }