@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.
- package/CHANGELOG.md +30 -0
- package/dist/assets/{ChannelsList-Zeys_w43.js → ChannelsList-NKNKsf1J.js} +1 -1
- package/dist/assets/ChatPage-p23OnnEI.js +43 -0
- package/dist/assets/DocBrowser-C8b2uPgL.js +1 -0
- package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DxdSujSc.js} +1 -1
- package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CQ-8jMha.js} +1 -1
- package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D-KQIN4U.js} +1 -1
- package/dist/assets/{MarketplacePage-Cd4faegU.js → MarketplacePage-CRNvxtvx.js} +2 -2
- package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
- package/dist/assets/{McpMarketplacePage-C09Ngs7O.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
- package/dist/assets/{ModelConfig-DJgdcgvQ.js → ModelConfig-CEpx9fro.js} +1 -1
- package/dist/assets/{ProvidersList-w0rVFIBf.js → ProvidersList-BWbUb7-2.js} +1 -1
- package/dist/assets/{RemoteAccessPage-BJ_ckkOV.js → RemoteAccessPage-NsawrZb0.js} +1 -1
- package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
- package/dist/assets/{SearchConfig-BT13qpR_.js → SearchConfig-BsaX_WYy.js} +1 -1
- package/dist/assets/{SecretsConfig-CvqEVn0B.js → SecretsConfig-CgDZOd3w.js} +1 -1
- package/dist/assets/{SessionsConfig-DHHcYznk.js → SessionsConfig-Dd-KM7F7.js} +2 -2
- package/dist/assets/{book-open-CXoF5nQC.js → book-open-FnK2xCQd.js} +1 -1
- package/dist/assets/chat-session-display-BD_AN71I.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-B5l0hr_u.js} +1 -1
- package/dist/assets/{config-DJswxxE8.js → config-JKmXfZ3q.js} +1 -1
- package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-o1WWhwhd.js} +1 -1
- package/dist/assets/{dist-nqTTbVdA.js → dist-C_moWYv7.js} +1 -1
- package/dist/assets/{dist-Cl2QB-2y.js → dist-DazA6Wd_.js} +1 -1
- package/dist/assets/{external-link-tIO7zING.js → external-link-BKje3SiD.js} +1 -1
- package/dist/assets/{hash-JWUyl1pT.js → hash-DfW4DT8O.js} +1 -1
- package/dist/assets/i18n-BK1w-oBy.js +1 -0
- package/dist/assets/index-BZaB1TqM.js +6 -0
- package/dist/assets/index-DaR9igPC.css +1 -0
- package/dist/assets/{label-BIpeNu4r.js → label-BzDWmdOe.js} +1 -1
- package/dist/assets/loader-circle-DdZPxBUz.js +1 -0
- package/dist/assets/{logos-DThdM9lk.js → logos-CTLlde_T.js} +1 -1
- package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-BagR3t59.js} +1 -1
- package/dist/assets/plus-DP2PSCPO.js +1 -0
- package/dist/assets/{popover-BJRUGA_H.js → popover-5DWhNfd4.js} +1 -1
- package/dist/assets/{provider-models-bz5y28rq.js → provider-models-DJ29qHuA.js} +1 -1
- package/dist/assets/{react-7ZHqQtEV.js → react-C3yu5yge.js} +1 -1
- package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BAJf-h7w.js} +1 -1
- package/dist/assets/{save-DJM5RRWW.js → save-aa6z4GJL.js} +1 -1
- package/dist/assets/search-pD6ZwQYF.js +1 -0
- package/dist/assets/{security-config-T5zpg16O.js → security-config-DRDxrApx.js} +1 -1
- package/dist/assets/{select-DSkTc61S.js → select-BHJPiJWt.js} +1 -1
- package/dist/assets/skeleton-D6kCk9Y6.js +1 -0
- package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-DUwsTIdv.js} +1 -1
- package/dist/assets/{switch-Bo-Y46HZ.js → switch-B6nCfcOB.js} +1 -1
- package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-B57SMElx.js} +1 -1
- package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-CrjYH5ok.js} +1 -1
- package/dist/assets/{useConfirmDialog-Bs5Ll17m.js → useConfirmDialog-DsxnXB1B.js} +1 -1
- package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-oTTWXgLG.js} +1 -1
- package/dist/assets/x-CTIQHUuD.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +5 -5
- package/src/App.tsx +2 -0
- package/src/api/agents.ts +26 -0
- package/src/api/types.ts +27 -2
- package/src/components/agents/AgentsPage.test.tsx +70 -0
- package/src/components/agents/AgentsPage.tsx +353 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +144 -8
- package/src/components/chat/ChatConversationPanel.tsx +136 -77
- package/src/components/chat/ChatSidebar.test.tsx +8 -0
- package/src/components/chat/ChatSidebar.tsx +11 -0
- package/src/components/chat/ChatWelcome.test.tsx +25 -0
- package/src/components/chat/ChatWelcome.tsx +47 -1
- package/src/components/chat/adapters/chat-message-part.adapter.ts +18 -5
- package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +102 -0
- package/src/components/chat/adapters/chat-message-tool-agent-id.ts +47 -0
- package/src/components/chat/adapters/chat-message.adapter.test.ts +89 -9
- package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +200 -0
- package/src/components/chat/chat-child-session-panel.tsx +166 -0
- package/src/components/chat/chat-page-runtime.test.ts +1 -0
- package/src/components/chat/chat-page-shell.tsx +8 -17
- package/src/components/chat/chat-session-display.test.ts +1 -0
- package/src/components/chat/chat-session-route.ts +0 -14
- package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
- package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
- package/src/components/chat/containers/chat-message-list.container.tsx +11 -0
- package/src/components/chat/ncp/NcpChatPage.tsx +153 -190
- package/src/components/chat/ncp/README.md +3 -0
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +106 -1
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +23 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +32 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
- package/src/components/chat/ncp/use-ncp-session-list-view.ts +10 -1
- package/src/components/chat/presenter/chat-presenter-context.tsx +5 -1
- package/src/components/chat/stores/chat-thread.store.ts +25 -1
- package/src/components/chat/useHydratedNcpAgent.test.tsx +30 -23
- package/src/components/common/AgentAvatar.tsx +63 -0
- package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
- package/src/components/common/agent-identity/index.ts +3 -0
- package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
- package/src/components/config/RuntimeConfig.tsx +13 -79
- package/src/components/config/runtime-config-agent.utils.ts +95 -0
- package/src/components/layout/AppLayout.tsx +3 -1
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/layout/app-layout.test.tsx +30 -0
- package/src/components/ui/tabs.tsx +2 -0
- package/src/hooks/README.md +3 -0
- package/src/hooks/agents/useAgents.ts +44 -0
- package/src/lib/i18n.agents.ts +66 -0
- package/src/lib/i18n.chat.ts +5 -0
- package/src/lib/i18n.ts +4 -4
- package/src/lib/ui-document-title.ts +1 -0
- package/dist/assets/ChatPage-DWOU_8P6.js +0 -43
- package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
- package/dist/assets/MarketplacePage-BfaTTqN6.js +0 -1
- package/dist/assets/RuntimeConfig-Cmn2xPQO.js +0 -1
- package/dist/assets/chat-session-display-VW6ZMvZP.js +0 -1
- package/dist/assets/i18n-CDHMXlRZ.js +0 -1
- package/dist/assets/index-BlH4-cBw.css +0 -1
- package/dist/assets/index-C6d0xmtm.js +0 -6
- package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
- package/dist/assets/plus-PHf8q-Ct.js +0 -1
- package/dist/assets/search-C91yH_6y.js +0 -1
- package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
- package/dist/assets/x-D7Q1yqSF.js +0 -1
- package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +0 -154
- /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
- /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
|
@@ -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',
|
|
@@ -39,9 +41,30 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
39
41
|
projectName: 'project-alpha',
|
|
40
42
|
sessionType: 'native',
|
|
41
43
|
sessionTypeMutable: false,
|
|
44
|
+
isChildSession: false,
|
|
42
45
|
messageCount: 3
|
|
43
46
|
});
|
|
44
47
|
});
|
|
48
|
+
|
|
49
|
+
it('marks child sessions from parent_session_id metadata and keeps the request link', () => {
|
|
50
|
+
const adapted = adaptNcpSessionSummary(
|
|
51
|
+
createSummary({
|
|
52
|
+
metadata: {
|
|
53
|
+
label: 'Verifier',
|
|
54
|
+
session_type: 'native',
|
|
55
|
+
parent_session_id: 'parent-session-1',
|
|
56
|
+
spawned_by_request_id: 'request-1',
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(adapted).toMatchObject({
|
|
62
|
+
key: 'ncp-session-1',
|
|
63
|
+
isChildSession: true,
|
|
64
|
+
parentSessionId: 'parent-session-1',
|
|
65
|
+
spawnedByRequestId: 'request-1',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
45
68
|
});
|
|
46
69
|
|
|
47
70
|
describe('adaptNcpMessageToUiMessage', () => {
|
|
@@ -88,6 +88,30 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
|
88
88
|
return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
|
|
92
|
+
const metadata = readMetadata(summary);
|
|
93
|
+
if (!metadata) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return readOptionalString(metadata.parent_session_id) ?? readOptionalString(metadata.parentSessionId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readNcpSpawnedByRequestId(summary: NcpSessionSummaryView): string | null {
|
|
100
|
+
const metadata = readMetadata(summary);
|
|
101
|
+
if (!metadata) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return readOptionalString(metadata.spawned_by_request_id) ?? readOptionalString(metadata.spawnedByRequestId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readPromotedChildSession(summary: NcpSessionSummaryView): boolean {
|
|
108
|
+
const metadata = readMetadata(summary);
|
|
109
|
+
if (!metadata) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return metadata.child_session_promoted === true;
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
function parseSessionContext(sessionKey: string): { channel?: string; type?: string } {
|
|
92
116
|
if (sessionKey === 'heartbeat') {
|
|
93
117
|
return { type: 'heartbeat' };
|
|
@@ -222,10 +246,14 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
222
246
|
const projectRoot = readNcpSessionProjectRoot(summary);
|
|
223
247
|
const projectName = getSessionProjectName(projectRoot);
|
|
224
248
|
const context = parseSessionContext(summary.sessionId);
|
|
249
|
+
const parentSessionId = readNcpParentSessionId(summary);
|
|
250
|
+
const spawnedByRequestId = readNcpSpawnedByRequestId(summary);
|
|
251
|
+
const isPromotedChildSession = readPromotedChildSession(summary);
|
|
225
252
|
return {
|
|
226
253
|
key: summary.sessionId,
|
|
227
254
|
createdAt: summary.updatedAt,
|
|
228
255
|
updatedAt: summary.updatedAt,
|
|
256
|
+
...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
|
|
229
257
|
...(label ? { label } : {}),
|
|
230
258
|
...context,
|
|
231
259
|
...(preferredModel ? { preferredModel } : {}),
|
|
@@ -234,6 +262,10 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
234
262
|
...(projectName ? { projectName } : {}),
|
|
235
263
|
sessionType: readNcpSessionType(summary),
|
|
236
264
|
sessionTypeMutable: false,
|
|
265
|
+
isChildSession: Boolean(parentSessionId),
|
|
266
|
+
...(isPromotedChildSession ? { isPromotedChildSession } : {}),
|
|
267
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
268
|
+
...(spawnedByRequestId ? { spawnedByRequestId } : {}),
|
|
237
269
|
messageCount: summary.messageCount
|
|
238
270
|
};
|
|
239
271
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
fetchNcpSessionMessages: vi.fn(),
|
|
7
|
+
hydratedCalls: [] as Array<{ client: unknown }>,
|
|
8
|
+
useHydratedNcpAgent: vi.fn(() => ({
|
|
9
|
+
snapshot: {
|
|
10
|
+
messages: [],
|
|
11
|
+
streamingMessage: null,
|
|
12
|
+
activeRun: null,
|
|
13
|
+
error: null,
|
|
14
|
+
},
|
|
15
|
+
visibleMessages: [],
|
|
16
|
+
activeRunId: null,
|
|
17
|
+
isRunning: false,
|
|
18
|
+
isSending: false,
|
|
19
|
+
send: vi.fn(),
|
|
20
|
+
abort: vi.fn(),
|
|
21
|
+
streamRun: vi.fn(),
|
|
22
|
+
isHydrating: false,
|
|
23
|
+
hydrateError: null,
|
|
24
|
+
})),
|
|
25
|
+
clientInstances: [] as unknown[],
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@/api/ncp-session", () => ({
|
|
29
|
+
fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("@nextclaw/ncp-react", () => ({
|
|
33
|
+
useHydratedNcpAgent: vi.fn((params: { client: unknown }) => {
|
|
34
|
+
mocks.hydratedCalls.push(params);
|
|
35
|
+
return mocks.useHydratedNcpAgent();
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("@nextclaw/ncp-http-agent-client", () => ({
|
|
40
|
+
NcpHttpAgentClientEndpoint: vi.fn().mockImplementation(function MockClient(this: object) {
|
|
41
|
+
mocks.clientInstances.push(this);
|
|
42
|
+
}),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
describe("useNcpSessionConversation", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mocks.fetchNcpSessionMessages.mockReset();
|
|
48
|
+
mocks.useHydratedNcpAgent.mockClear();
|
|
49
|
+
mocks.hydratedCalls.length = 0;
|
|
50
|
+
mocks.clientInstances.length = 0;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("hydrates seed from the shared session messages endpoint payload", async () => {
|
|
54
|
+
mocks.fetchNcpSessionMessages.mockResolvedValue({
|
|
55
|
+
sessionId: "session-1",
|
|
56
|
+
status: "running",
|
|
57
|
+
total: 1,
|
|
58
|
+
messages: [{ id: "msg-1" }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await fetchNcpSessionConversationSeed(
|
|
62
|
+
"session-1",
|
|
63
|
+
new AbortController().signal,
|
|
64
|
+
300,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledWith("session-1", 300);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
messages: [{ id: "msg-1" }],
|
|
70
|
+
status: "running",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats a missing session as an empty idle draft seed", async () => {
|
|
75
|
+
mocks.fetchNcpSessionMessages.mockRejectedValue(
|
|
76
|
+
new Error("ncp session not found: draft-session"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = await fetchNcpSessionConversationSeed(
|
|
80
|
+
"draft-session",
|
|
81
|
+
new AbortController().signal,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({
|
|
85
|
+
messages: [],
|
|
86
|
+
status: "idle",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("creates an isolated endpoint instance per viewer", () => {
|
|
91
|
+
renderHook(() => useNcpSessionConversation("session-a"));
|
|
92
|
+
renderHook(() => useNcpSessionConversation("session-b"));
|
|
93
|
+
|
|
94
|
+
expect(mocks.useHydratedNcpAgent).toHaveBeenCalledTimes(2);
|
|
95
|
+
expect(mocks.clientInstances).toHaveLength(2);
|
|
96
|
+
expect(mocks.hydratedCalls).toHaveLength(2);
|
|
97
|
+
expect(mocks.clientInstances[0]).not.toBe(mocks.clientInstances[1]);
|
|
98
|
+
expect(mocks.hydratedCalls[0]?.client).toBe(mocks.clientInstances[0]);
|
|
99
|
+
expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
|
|
3
|
+
import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
|
|
4
|
+
import { API_BASE } from "@/api/api-base";
|
|
5
|
+
import { fetchNcpSessionMessages } from "@/api/ncp-session";
|
|
6
|
+
import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MESSAGE_LIMIT = 300;
|
|
9
|
+
|
|
10
|
+
type UseNcpSessionConversationOptions = {
|
|
11
|
+
messageLimit?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function isMissingNcpSessionError(error: unknown): boolean {
|
|
15
|
+
if (!(error instanceof Error)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return error.message.includes("ncp session not found:");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createNcpSessionConversationClient(): NcpHttpAgentClientEndpoint {
|
|
22
|
+
return new NcpHttpAgentClientEndpoint({
|
|
23
|
+
baseUrl: API_BASE,
|
|
24
|
+
basePath: "/api/ncp/agent",
|
|
25
|
+
fetchImpl: createNcpAppClientFetch(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function fetchNcpSessionConversationSeed(
|
|
30
|
+
sessionId: string,
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
messageLimit = DEFAULT_MESSAGE_LIMIT,
|
|
33
|
+
): Promise<NcpConversationSeed> {
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchNcpSessionMessages(sessionId, messageLimit);
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
return {
|
|
40
|
+
messages: response.messages,
|
|
41
|
+
status: response.status ?? "idle",
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
if (!isMissingNcpSessionError(error)) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
messages: [],
|
|
50
|
+
status: "idle",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useNcpSessionConversation(
|
|
56
|
+
sessionId: string,
|
|
57
|
+
options: UseNcpSessionConversationOptions = {},
|
|
58
|
+
) {
|
|
59
|
+
const [client] = useState(() => createNcpSessionConversationClient());
|
|
60
|
+
const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
|
|
61
|
+
const loadSeed = useCallback(
|
|
62
|
+
(targetSessionId: string, signal: AbortSignal) =>
|
|
63
|
+
fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit),
|
|
64
|
+
[messageLimit],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return useHydratedNcpAgent({
|
|
68
|
+
sessionId,
|
|
69
|
+
client,
|
|
70
|
+
loadSeed,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -15,13 +15,22 @@ function filterSessionsByQuery(sessions: readonly SessionEntryView[], query: str
|
|
|
15
15
|
return sessions.filter((session) => sessionMatchesQuery(session, query));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
|
|
19
|
+
if (!session.isChildSession) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return session.isPromotedChildSession === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export function useNcpSessionListView(params: { limit?: number } = {}) {
|
|
19
26
|
const query = useChatSessionListStore((state) => state.snapshot.query);
|
|
20
27
|
const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
|
|
21
28
|
|
|
22
29
|
const items = useMemo<NcpSessionListItemView[]>(() => {
|
|
23
30
|
const summaries = sessionsQuery.data?.sessions ?? [];
|
|
24
|
-
const sessions = adaptNcpSessionSummaries(summaries)
|
|
31
|
+
const sessions = adaptNcpSessionSummaries(summaries).filter(
|
|
32
|
+
shouldShowSessionInSidebar,
|
|
33
|
+
);
|
|
25
34
|
const filteredSessions = filterSessionsByQuery(sessions, query);
|
|
26
35
|
const summaryBySessionId = new Map(summaries.map((summary) => [summary.sessionId, summary]));
|
|
27
36
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
1
|
+
import type { ChatComposerNode, ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
|
|
2
2
|
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
3
|
import { createContext, useContext } from 'react';
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
@@ -37,6 +37,10 @@ export type ChatThreadManagerLike = {
|
|
|
37
37
|
deleteSession: () => void;
|
|
38
38
|
createSession: () => void;
|
|
39
39
|
goToProviders: () => void;
|
|
40
|
+
openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
|
|
41
|
+
selectChildSessionDetail: (sessionKey: string) => void;
|
|
42
|
+
closeChildSessionDetail: () => void;
|
|
43
|
+
goToParentSession: () => void;
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
export type ChatPresenterLike = {
|
|
@@ -2,6 +2,14 @@ import { create } from 'zustand';
|
|
|
2
2
|
import type { MutableRefObject } from 'react';
|
|
3
3
|
import type { NcpMessage } from '@nextclaw/ncp';
|
|
4
4
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
|
+
import type { AgentProfileView } from '@/api/types';
|
|
6
|
+
|
|
7
|
+
export type ChatChildSessionTab = {
|
|
8
|
+
sessionKey: string;
|
|
9
|
+
parentSessionKey: string | null;
|
|
10
|
+
label?: string | null;
|
|
11
|
+
agentId?: string | null;
|
|
12
|
+
};
|
|
5
13
|
|
|
6
14
|
export type ChatThreadSnapshot = {
|
|
7
15
|
isProviderStateResolved: boolean;
|
|
@@ -10,6 +18,10 @@ export type ChatThreadSnapshot = {
|
|
|
10
18
|
sessionTypeUnavailableMessage?: string | null;
|
|
11
19
|
sessionTypeLabel?: string | null;
|
|
12
20
|
sessionKey: string | null;
|
|
21
|
+
agentId?: string | null;
|
|
22
|
+
agentDisplayName?: string | null;
|
|
23
|
+
agentAvatarUrl?: string | null;
|
|
24
|
+
availableAgents?: AgentProfileView[];
|
|
13
25
|
sessionDisplayName?: string;
|
|
14
26
|
sessionProjectRoot?: string | null;
|
|
15
27
|
sessionProjectName?: string | null;
|
|
@@ -20,6 +32,10 @@ export type ChatThreadSnapshot = {
|
|
|
20
32
|
messages: readonly NcpMessage[];
|
|
21
33
|
isSending: boolean;
|
|
22
34
|
isAwaitingAssistantOutput: boolean;
|
|
35
|
+
parentSessionKey?: string | null;
|
|
36
|
+
parentSessionLabel?: string | null;
|
|
37
|
+
childSessionTabs: ChatChildSessionTab[];
|
|
38
|
+
activeChildSessionKey?: string | null;
|
|
23
39
|
};
|
|
24
40
|
|
|
25
41
|
type ChatThreadStore = {
|
|
@@ -34,6 +50,10 @@ const initialSnapshot: ChatThreadSnapshot = {
|
|
|
34
50
|
sessionTypeUnavailableMessage: null,
|
|
35
51
|
sessionTypeLabel: null,
|
|
36
52
|
sessionKey: null,
|
|
53
|
+
agentId: null,
|
|
54
|
+
agentDisplayName: null,
|
|
55
|
+
agentAvatarUrl: null,
|
|
56
|
+
availableAgents: [],
|
|
37
57
|
sessionDisplayName: undefined,
|
|
38
58
|
sessionProjectRoot: null,
|
|
39
59
|
sessionProjectName: null,
|
|
@@ -43,7 +63,11 @@ const initialSnapshot: ChatThreadSnapshot = {
|
|
|
43
63
|
isHistoryLoading: false,
|
|
44
64
|
messages: [],
|
|
45
65
|
isSending: false,
|
|
46
|
-
isAwaitingAssistantOutput: false
|
|
66
|
+
isAwaitingAssistantOutput: false,
|
|
67
|
+
parentSessionKey: null,
|
|
68
|
+
parentSessionLabel: null,
|
|
69
|
+
childSessionTabs: [],
|
|
70
|
+
activeChildSessionKey: null,
|
|
47
71
|
};
|
|
48
72
|
|
|
49
73
|
export const useChatThreadStore = create<ChatThreadStore>((set) => ({
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import { renderHook, waitFor } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import type { NcpAgentClientEndpoint } from "@nextclaw/ncp";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useHydratedNcpAgent } from "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts";
|
|
4
5
|
|
|
5
6
|
const mocks = vi.hoisted(() => ({
|
|
6
7
|
manager: {
|
|
7
8
|
reset: vi.fn(),
|
|
8
|
-
hydrate: vi.fn()
|
|
9
|
+
hydrate: vi.fn(),
|
|
9
10
|
},
|
|
10
11
|
runtime: {
|
|
11
12
|
snapshot: {
|
|
12
13
|
messages: [],
|
|
13
14
|
streamingMessage: null,
|
|
14
15
|
activeRun: null,
|
|
15
|
-
error: null
|
|
16
|
+
error: null,
|
|
16
17
|
},
|
|
17
18
|
visibleMessages: [],
|
|
18
19
|
activeRunId: null,
|
|
@@ -20,16 +21,19 @@ const mocks = vi.hoisted(() => ({
|
|
|
20
21
|
isSending: false,
|
|
21
22
|
send: vi.fn(),
|
|
22
23
|
abort: vi.fn(),
|
|
23
|
-
streamRun: vi.fn()
|
|
24
|
-
}
|
|
24
|
+
streamRun: vi.fn(),
|
|
25
|
+
},
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
|
-
vi.mock(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
vi.mock(
|
|
29
|
+
"../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js",
|
|
30
|
+
() => ({
|
|
31
|
+
useScopedAgentManager: () => mocks.manager,
|
|
32
|
+
useNcpAgentRuntime: () => mocks.runtime,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
31
35
|
|
|
32
|
-
describe(
|
|
36
|
+
describe("useHydratedNcpAgent", () => {
|
|
33
37
|
beforeEach(() => {
|
|
34
38
|
mocks.manager.reset.mockReset();
|
|
35
39
|
mocks.manager.hydrate.mockReset();
|
|
@@ -38,40 +42,43 @@ describe('useHydratedNcpAgent', () => {
|
|
|
38
42
|
mocks.runtime.streamRun.mockReset();
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
it(
|
|
45
|
+
it("treats a newly selected session as hydrating immediately on rerender", async () => {
|
|
42
46
|
const client = {
|
|
43
47
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
44
|
-
stream: vi.fn().mockResolvedValue(undefined)
|
|
45
|
-
}
|
|
48
|
+
stream: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
} satisfies Pick<NcpAgentClientEndpoint, "stop" | "stream">;
|
|
46
50
|
const loadSeed = vi
|
|
47
51
|
.fn()
|
|
48
|
-
.mockResolvedValueOnce({ messages: [], status:
|
|
49
|
-
.mockResolvedValueOnce({ messages: [], status:
|
|
52
|
+
.mockResolvedValueOnce({ messages: [], status: "idle" })
|
|
53
|
+
.mockResolvedValueOnce({ messages: [], status: "idle" });
|
|
50
54
|
|
|
51
55
|
const { result, rerender } = renderHook(
|
|
52
56
|
({ sessionId }: { sessionId: string }) =>
|
|
53
57
|
useHydratedNcpAgent({
|
|
54
58
|
sessionId,
|
|
55
|
-
client,
|
|
56
|
-
loadSeed
|
|
59
|
+
client: client as never,
|
|
60
|
+
loadSeed,
|
|
57
61
|
}),
|
|
58
62
|
{
|
|
59
63
|
initialProps: {
|
|
60
|
-
sessionId:
|
|
61
|
-
}
|
|
62
|
-
}
|
|
64
|
+
sessionId: "session-a",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
63
67
|
);
|
|
64
68
|
|
|
65
69
|
await waitFor(() => {
|
|
66
70
|
expect(result.current.isHydrating).toBe(false);
|
|
67
71
|
});
|
|
72
|
+
expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-a" });
|
|
68
73
|
|
|
69
|
-
rerender({ sessionId:
|
|
74
|
+
rerender({ sessionId: "session-b" });
|
|
70
75
|
|
|
71
76
|
expect(result.current.isHydrating).toBe(true);
|
|
72
77
|
|
|
73
78
|
await waitFor(() => {
|
|
74
79
|
expect(result.current.isHydrating).toBe(false);
|
|
75
80
|
});
|
|
81
|
+
expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-b" });
|
|
82
|
+
expect(client.stream).toHaveBeenCalledTimes(2);
|
|
76
83
|
});
|
|
77
84
|
});
|