@nextclaw/ui 0.10.0 → 0.10.2

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 (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-DSMuOmMG.js} +4 -4
  3. package/dist/assets/ChatPage-do9TwNxj.js +38 -0
  4. package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-BjoTblYl.js} +1 -1
  5. package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-2yDaYdxw.js} +1 -1
  6. package/dist/assets/MarketplacePage-DVVk4dlH.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-B4WUzuLw.js} +1 -1
  8. package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-Dr0eI9nN.js} +1 -1
  9. package/dist/assets/ProvidersList-C7A-mIbe.js +1 -0
  10. package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-CI3Am3w1.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-DvSNVSs8.js} +1 -1
  12. package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-B6TGIZow.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-CpxaKU1j.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-B-VHnv4G.js} +1 -1
  15. package/dist/assets/{chat-message-BOdA4h43.js → chat-message-BMqngrjp.js} +1 -1
  16. package/dist/assets/index-C6MeoecJ.js +8 -0
  17. package/dist/assets/index-DdXzLuNG.css +1 -0
  18. package/dist/assets/{label-BYZ62ajO.js → label-s2ILtQeP.js} +1 -1
  19. package/dist/assets/{page-layout-UC-h92sU.js → page-layout-BX5Ro4Sj.js} +1 -1
  20. package/dist/assets/{popover-DASCEr3G.js → popover-qmNpQSIy.js} +1 -1
  21. package/dist/assets/{security-config-Cvujq4fH.js → security-config--F-f-nDl.js} +1 -1
  22. package/dist/assets/skeleton-DthPOKSc.js +1 -0
  23. package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DWj7aUy8.js} +1 -1
  24. package/dist/assets/{switch-D3wVuCSh.js → switch-62r7L4Lj.js} +1 -1
  25. package/dist/assets/tabs-custom-DEmoGMsc.js +1 -0
  26. package/dist/assets/useConfirmDialog-DzT94nC_.js +1 -0
  27. package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
  28. package/dist/index.html +3 -3
  29. package/package.json +5 -5
  30. package/src/App.test.tsx +41 -0
  31. package/src/App.tsx +37 -0
  32. package/src/api/client.test.ts +12 -0
  33. package/src/api/client.ts +4 -2
  34. package/src/api/config.ts +1 -1
  35. package/src/components/chat/ChatSidebar.tsx +41 -69
  36. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
  38. package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
  39. package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
  40. package/src/components/chat/chat-composer-state.ts +38 -0
  41. package/src/components/chat/chat-stream/types.ts +2 -0
  42. package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
  43. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  44. package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
  45. package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
  46. package/src/components/chat/managers/chat-thread.manager.ts +0 -1
  47. package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
  48. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
  49. package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
  50. package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
  51. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
  52. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
  53. package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
  54. package/src/components/chat/stores/chat-input.store.ts +3 -0
  55. package/src/components/config/ChannelsList.test.tsx +2 -1
  56. package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
  57. package/src/components/layout/Sidebar.tsx +62 -102
  58. package/src/components/layout/sidebar-items.tsx +172 -0
  59. package/src/components/layout/sidebar.layout.test.tsx +11 -4
  60. package/src/hooks/use-auth.ts +1 -2
  61. package/src/lib/i18n.chat.ts +117 -0
  62. package/src/lib/i18n.remote.ts +1 -1
  63. package/src/lib/i18n.ts +2 -112
  64. package/src/transport/local.transport.ts +28 -7
  65. package/src/transport/remote.transport.test.ts +135 -0
  66. package/src/transport/remote.transport.ts +14 -1
  67. package/src/transport/transport.types.ts +1 -0
  68. package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
  69. package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
  70. package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
  71. package/dist/assets/index-C63mHRbE.css +0 -1
  72. package/dist/assets/index-DS7D1-KS.js +0 -8
  73. package/dist/assets/skeleton-DlYEKkkj.js +0 -1
  74. package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
  75. package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
@@ -14,6 +14,12 @@ export type ChatMessagePartSource =
14
14
  type: 'text';
15
15
  text: string;
16
16
  }
17
+ | {
18
+ type: 'file';
19
+ mimeType: string;
20
+ data: string;
21
+ name?: string;
22
+ }
17
23
  | {
18
24
  type: 'reasoning';
19
25
  reasoning: string;
@@ -58,6 +64,8 @@ export type ChatMessageAdapterTexts = {
58
64
  toolResultLabel: string;
59
65
  toolNoOutputLabel: string;
60
66
  toolOutputLabel: string;
67
+ imageAttachmentLabel: string;
68
+ fileAttachmentLabel: string;
61
69
  unknownPartLabel: string;
62
70
  };
63
71
 
@@ -77,6 +85,16 @@ function isReasoningPart(
77
85
  return part.type === 'reasoning' && typeof part.reasoning === 'string';
78
86
  }
79
87
 
88
+ function isFilePart(
89
+ part: ChatMessagePartSource
90
+ ): part is Extract<ChatMessagePartSource, { type: 'file' }> {
91
+ return (
92
+ part.type === 'file' &&
93
+ typeof part.mimeType === 'string' &&
94
+ typeof part.data === 'string'
95
+ );
96
+ }
97
+
80
98
  function isToolInvocationPart(
81
99
  part: ChatMessagePartSource
82
100
  ): part is Extract<ChatMessagePartSource, { type: 'tool-invocation' }> {
@@ -182,6 +200,23 @@ export function adaptChatMessages(params: {
182
200
  label: params.texts.reasoningLabel
183
201
  };
184
202
  }
203
+ if (isFilePart(part)) {
204
+ const isImage = part.mimeType.startsWith('image/');
205
+ return {
206
+ type: 'file' as const,
207
+ file: {
208
+ label:
209
+ typeof part.name === 'string' && part.name.trim()
210
+ ? part.name.trim()
211
+ : isImage
212
+ ? params.texts.imageAttachmentLabel
213
+ : params.texts.fileAttachmentLabel,
214
+ mimeType: part.mimeType,
215
+ dataUrl: `data:${part.mimeType};base64,${part.data}`,
216
+ isImage
217
+ }
218
+ };
219
+ }
185
220
  if (isToolInvocationPart(part)) {
186
221
  const invocation = part.toolInvocation;
187
222
  const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
2
3
  import {
3
4
  createChatComposerTokenNode,
4
5
  createChatComposerNodesFromText,
@@ -25,6 +26,10 @@ export function deriveSelectedSkillsFromComposer(nodes: ChatComposerNode[]): str
25
26
  return extractChatComposerTokenKeys(nodes, 'skill');
26
27
  }
27
28
 
29
+ export function deriveSelectedAttachmentIdsFromComposer(nodes: ChatComposerNode[]): string[] {
30
+ return extractChatComposerTokenKeys(nodes, 'file');
31
+ }
32
+
28
33
  export function syncComposerSkills(
29
34
  nodes: ChatComposerNode[],
30
35
  nextSkills: string[],
@@ -51,3 +56,36 @@ export function syncComposerSkills(
51
56
  ? prunedNodes
52
57
  : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
53
58
  }
59
+
60
+ export function syncComposerAttachments(
61
+ nodes: ChatComposerNode[],
62
+ attachments: readonly NcpDraftAttachment[]
63
+ ): ChatComposerNode[] {
64
+ const nextAttachmentIds = new Set(attachments.map((attachment) => attachment.id));
65
+ const prunedNodes = removeChatComposerTokenNodes(
66
+ nodes,
67
+ (node) => node.tokenKind === 'file' && !nextAttachmentIds.has(node.tokenKey)
68
+ );
69
+ const existingAttachmentIds = extractChatComposerTokenKeys(prunedNodes, 'file');
70
+ const appendedNodes = attachments
71
+ .filter((attachment) => !existingAttachmentIds.includes(attachment.id))
72
+ .map((attachment) =>
73
+ createChatComposerTokenNode({
74
+ tokenKind: 'file',
75
+ tokenKey: attachment.id,
76
+ label: attachment.name
77
+ })
78
+ );
79
+
80
+ return appendedNodes.length === 0
81
+ ? prunedNodes
82
+ : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
83
+ }
84
+
85
+ export function pruneComposerAttachments(
86
+ nodes: ChatComposerNode[],
87
+ attachments: readonly NcpDraftAttachment[]
88
+ ): NcpDraftAttachment[] {
89
+ const selectedIds = new Set(deriveSelectedAttachmentIdsFromComposer(nodes));
90
+ return attachments.filter((attachment) => selectedIds.has(attachment.id));
91
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
2
3
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
4
  import type {
4
5
  ChatRunView,
@@ -18,6 +19,7 @@ export type SendMessageParams = {
18
19
  model?: string;
19
20
  thinkingLevel?: ThinkingLevel;
20
21
  requestedSkills?: string[];
22
+ attachments?: NcpDraftAttachment[];
21
23
  stopSupported?: boolean;
22
24
  stopReason?: string;
23
25
  restoreDraftOnError?: boolean;
@@ -1,5 +1,10 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
2
  import { ChatInputBar } from '@nextclaw/agent-chat-ui';
3
+ import {
4
+ DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT,
5
+ DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES,
6
+ readFilesAsNcpDraftAttachments
7
+ } from '@nextclaw/ncp-react';
3
8
  import {
4
9
  buildChatSlashItems,
5
10
  buildModelStateHint,
@@ -14,6 +19,7 @@ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context
14
19
  import { useI18n } from '@/components/providers/I18nProvider';
15
20
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
16
21
  import { t } from '@/lib/i18n';
22
+ import { toast } from 'sonner';
17
23
 
18
24
  function buildThinkingLabels(): Record<ChatThinkingLevel, string> {
19
25
  return {
@@ -70,6 +76,7 @@ export function ChatInputBarContainer() {
70
76
  const { language } = useI18n();
71
77
  const snapshot = useChatInputStore((state) => state.snapshot);
72
78
  const [slashQuery, setSlashQuery] = useState<string | null>(null);
79
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
73
80
 
74
81
  const officialSkillBadgeLabel = useMemo(() => {
75
82
  // Keep memo reactive to locale switches even though `t` is imported as a stable function.
@@ -102,6 +109,7 @@ export function ChatInputBarContainer() {
102
109
  const isModelOptionsEmpty = snapshot.isProviderStateResolved && !hasModelOptions;
103
110
  const inputDisabled =
104
111
  ((isModelOptionsLoading || isModelOptionsEmpty) && !snapshot.isSending) || snapshot.sessionTypeUnavailable;
112
+ const attachmentSupported = typeof presenter.chatInputManager.addAttachments === 'function';
105
113
  const textareaPlaceholder = isModelOptionsLoading
106
114
  ? ''
107
115
  : hasModelOptions
@@ -122,6 +130,33 @@ export function ChatInputBarContainer() {
122
130
  ? t('chatStopPreparing')
123
131
  : snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
124
132
 
133
+ const showAttachmentError = useCallback((reason: 'unsupported-type' | 'too-large' | 'read-failed') => {
134
+ if (reason === 'unsupported-type') {
135
+ toast.error(t('chatInputImageUnsupported'));
136
+ return;
137
+ }
138
+ if (reason === 'too-large') {
139
+ toast.error(
140
+ t('chatInputImageTooLarge').replace('{maxMb}', String(DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES / (1024 * 1024)))
141
+ );
142
+ return;
143
+ }
144
+ toast.error(t('chatInputImageReadFailed'));
145
+ }, []);
146
+
147
+ const handleFilesAdd = useCallback(async (files: File[]) => {
148
+ if (!attachmentSupported || files.length === 0) {
149
+ return;
150
+ }
151
+ const result = await readFilesAsNcpDraftAttachments(files);
152
+ if (result.attachments.length > 0) {
153
+ presenter.chatInputManager.addAttachments?.(result.attachments);
154
+ }
155
+ if (result.rejected.length > 0) {
156
+ showAttachmentError(result.rejected[0].reason);
157
+ }
158
+ }, [attachmentSupported, presenter.chatInputManager, showAttachmentError]);
159
+
125
160
  const toolbarSelects = [
126
161
  buildModelToolbarSelect({
127
162
  modelOptions: modelRecords,
@@ -160,60 +195,86 @@ export function ChatInputBarContainer() {
160
195
  });
161
196
 
162
197
  return (
163
- <ChatInputBar
164
- composer={{
165
- nodes: snapshot.composerNodes,
166
- placeholder: textareaPlaceholder,
167
- disabled: inputDisabled,
168
- onNodesChange: presenter.chatInputManager.setComposerNodes,
169
- onSlashQueryChange: setSlashQuery
170
- }}
171
- slashMenu={{
172
- isLoading: snapshot.isSkillsLoading,
173
- items: slashItems,
174
- texts: {
175
- slashLoadingLabel: t('chatSlashLoading'),
176
- slashSectionLabel: t('chatSlashSectionSkills'),
177
- slashEmptyLabel: t('chatSlashNoResult'),
178
- slashHintLabel: t('chatSlashHint'),
179
- slashSkillHintLabel: t('chatSlashSkillHint')
180
- }
181
- }}
182
- hint={buildModelStateHint({
183
- isModelOptionsLoading,
184
- isModelOptionsEmpty,
185
- onGoToProviders: presenter.chatInputManager.goToProviders,
186
- texts: {
187
- noModelOptionsLabel: t('chatModelNoOptions'),
188
- configureProviderLabel: t('chatGoConfigureProvider')
189
- }
190
- })}
191
- toolbar={{
192
- selects: toolbarSelects,
193
- accessories: [
194
- {
195
- key: 'attach',
196
- label: t('chatInputAttach'),
197
- icon: 'paperclip',
198
- iconOnly: true,
199
- disabled: true,
200
- tooltip: t('chatInputAttachComingSoon')
198
+ <>
199
+ <ChatInputBar
200
+ composer={{
201
+ nodes: snapshot.composerNodes,
202
+ placeholder: textareaPlaceholder,
203
+ disabled: inputDisabled,
204
+ onNodesChange: presenter.chatInputManager.setComposerNodes,
205
+ ...(attachmentSupported ? { onFilesAdd: handleFilesAdd } : {}),
206
+ onSlashQueryChange: setSlashQuery
207
+ }}
208
+ slashMenu={{
209
+ isLoading: snapshot.isSkillsLoading,
210
+ items: slashItems,
211
+ texts: {
212
+ slashLoadingLabel: t('chatSlashLoading'),
213
+ slashSectionLabel: t('chatSlashSectionSkills'),
214
+ slashEmptyLabel: t('chatSlashNoResult'),
215
+ slashHintLabel: t('chatSlashHint'),
216
+ slashSkillHintLabel: t('chatSlashSkillHint')
201
217
  }
202
- ],
203
- skillPicker,
204
- actions: {
205
- sendError: snapshot.sendError,
206
- isSending: snapshot.isSending,
207
- canStopGeneration: snapshot.canStopGeneration,
208
- sendDisabled: snapshot.draft.trim().length === 0 || !hasModelOptions || snapshot.sessionTypeUnavailable,
209
- stopDisabled: !snapshot.canStopGeneration,
210
- stopHint: resolvedStopHint,
211
- sendButtonLabel: t('chatSend'),
212
- stopButtonLabel: t('chatStop'),
213
- onSend: presenter.chatInputManager.send,
214
- onStop: presenter.chatInputManager.stop
215
- }
216
- }}
217
- />
218
+ }}
219
+ hint={buildModelStateHint({
220
+ isModelOptionsLoading,
221
+ isModelOptionsEmpty,
222
+ onGoToProviders: presenter.chatInputManager.goToProviders,
223
+ texts: {
224
+ noModelOptionsLabel: t('chatModelNoOptions'),
225
+ configureProviderLabel: t('chatGoConfigureProvider')
226
+ }
227
+ })}
228
+ toolbar={{
229
+ selects: toolbarSelects,
230
+ accessories: [
231
+ {
232
+ key: 'attach',
233
+ label: t('chatInputAttach'),
234
+ icon: 'paperclip',
235
+ iconOnly: true,
236
+ disabled: !attachmentSupported || inputDisabled || snapshot.isSending,
237
+ ...(attachmentSupported
238
+ ? {
239
+ onClick: () => fileInputRef.current?.click()
240
+ }
241
+ : {
242
+ tooltip: t('chatInputAttachComingSoon')
243
+ })
244
+ }
245
+ ],
246
+ skillPicker,
247
+ actions: {
248
+ sendError: snapshot.sendError,
249
+ isSending: snapshot.isSending,
250
+ canStopGeneration: snapshot.canStopGeneration,
251
+ sendDisabled:
252
+ (snapshot.draft.trim().length === 0 && snapshot.attachments.length === 0) ||
253
+ !hasModelOptions ||
254
+ snapshot.sessionTypeUnavailable,
255
+ stopDisabled: !snapshot.canStopGeneration,
256
+ stopHint: resolvedStopHint,
257
+ sendButtonLabel: t('chatSend'),
258
+ stopButtonLabel: t('chatStop'),
259
+ onSend: presenter.chatInputManager.send,
260
+ onStop: presenter.chatInputManager.stop
261
+ }
262
+ }}
263
+ />
264
+ {attachmentSupported ? (
265
+ <input
266
+ ref={fileInputRef}
267
+ type="file"
268
+ accept={DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT}
269
+ multiple
270
+ className="hidden"
271
+ onChange={async (event) => {
272
+ const files = Array.from(event.target.files ?? []);
273
+ event.currentTarget.value = '';
274
+ await handleFilesAdd(files);
275
+ }}
276
+ />
277
+ ) : null}
278
+ </>
218
279
  );
219
280
  }
@@ -48,6 +48,8 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
48
48
  toolResultLabel: t("chatToolResult"),
49
49
  toolNoOutputLabel: t("chatToolNoOutput"),
50
50
  toolOutputLabel: t("chatToolOutput"),
51
+ imageAttachmentLabel: t("chatImageAttachment"),
52
+ fileAttachmentLabel: t("chatFileAttachment"),
51
53
  unknownPartLabel: t("chatUnknownPart"),
52
54
  },
53
55
  }),
@@ -33,7 +33,22 @@ describe('ChatSessionListManager', () => {
33
33
 
34
34
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
35
35
  expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
36
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
36
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
37
37
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
38
38
  });
39
+
40
+ it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
41
+ const uiManager = {
42
+ goToSession: vi.fn()
43
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
44
+ const streamActionsManager = {
45
+ resetStreamState: vi.fn()
46
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
47
+
48
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
49
+ manager.selectSession('session-2');
50
+
51
+ expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
52
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
53
+ });
39
54
  });
@@ -62,13 +62,11 @@ export class ChatSessionListManager {
62
62
  ? sessionType.trim()
63
63
  : defaultSessionType;
64
64
  this.streamActionsManager.resetStreamState();
65
- this.setSelectedSessionKey(null);
66
65
  useChatInputStore.getState().setSnapshot({ pendingSessionType: nextSessionType });
67
66
  this.uiManager.goToChatRoot();
68
67
  };
69
68
 
70
69
  selectSession = (sessionKey: string) => {
71
- this.setSelectedSessionKey(sessionKey);
72
70
  this.uiManager.goToSession(sessionKey);
73
71
  };
74
72
 
@@ -78,7 +78,6 @@ export class ChatThreadManager {
78
78
  try {
79
79
  await deleteSessionApi(selectedSessionKey);
80
80
  this.streamActionsManager.resetStreamState();
81
- useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
82
81
  this.uiManager.goToChatRoot({ replace: true });
83
82
  await this.actions.refetchSessions();
84
83
  } finally {
@@ -1,6 +1,10 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { NcpHttpAgentClientEndpoint } from '@nextclaw/ncp-http-agent-client';
3
- import { useHydratedNcpAgent, type NcpConversationSeed } from '@nextclaw/ncp-react';
3
+ import {
4
+ buildNcpRequestEnvelope,
5
+ useHydratedNcpAgent,
6
+ type NcpConversationSeed
7
+ } from '@nextclaw/ncp-react';
4
8
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
5
9
  import { API_BASE } from '@/api/api-base';
6
10
  import { fetchNcpSessionMessages } from '@/api/ncp-session';
@@ -186,29 +190,25 @@ export function NcpChatPage({ view }: ChatPageProps) {
186
190
  sessionType: payload.sessionType,
187
191
  requestedSkills: payload.requestedSkills
188
192
  });
193
+ const envelope = buildNcpRequestEnvelope({
194
+ sessionId: payload.sessionKey,
195
+ text: payload.message,
196
+ attachments: payload.attachments,
197
+ metadata
198
+ });
199
+ if (!envelope) {
200
+ return;
201
+ }
189
202
  try {
190
203
  void sessionsQuery.refetch();
191
- await agent.send({
192
- sessionId: payload.sessionKey,
193
- message: {
194
- id: `user-${Date.now().toString(36)}`,
195
- sessionId: payload.sessionKey,
196
- role: 'user',
197
- status: 'final',
198
- parts: [{ type: 'text', text: payload.message }],
199
- timestamp: new Date().toISOString(),
200
- ...(Object.keys(metadata).length > 0 ? { metadata } : {})
201
- },
202
- ...(Object.keys(metadata).length > 0 ? { metadata } : {})
203
- });
204
+ await agent.send(envelope);
204
205
  await sessionsQuery.refetch();
205
206
  } catch (error) {
206
207
  if (payload.restoreDraftOnError) {
207
208
  if (payload.composerNodes && payload.composerNodes.length > 0) {
208
- presenter.chatInputManager.setComposerNodes((currentNodes) =>
209
- currentNodes.length === 1 && currentNodes[0]?.type === 'text' && currentNodes[0].text.length === 0
210
- ? payload.composerNodes ?? currentNodes
211
- : currentNodes
209
+ presenter.chatInputManager.restoreComposerState?.(
210
+ payload.composerNodes,
211
+ payload.attachments ?? []
212
212
  );
213
213
  } else {
214
214
  presenter.chatInputManager.setDraft((currentDraft) =>
@@ -1,25 +1,25 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1
2
  import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
2
3
 
3
- const mocks = vi.hoisted(() => ({
4
- request: vi.fn(),
5
- openStream: vi.fn()
6
- }));
7
-
8
- vi.mock('@/transport', () => ({
9
- appClient: {
10
- request: mocks.request,
11
- openStream: mocks.openStream
12
- }
13
- }));
4
+ const fetchMock = vi.fn<typeof fetch>();
14
5
 
15
6
  describe('ncp-app-client-fetch', () => {
16
7
  beforeEach(() => {
17
- mocks.request.mockReset();
18
- mocks.openStream.mockReset();
8
+ fetchMock.mockReset();
9
+ vi.stubGlobal('fetch', fetchMock);
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
19
14
  });
20
15
 
21
- it('routes JSON requests through appClient.request', async () => {
22
- mocks.request.mockResolvedValue({ stopped: true });
16
+ it('keeps native fetch semantics and only injects credentials', async () => {
17
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
18
+ status: 200,
19
+ headers: {
20
+ 'content-type': 'application/json'
21
+ }
22
+ }));
23
23
  const fetchImpl = createNcpAppClientFetch();
24
24
 
25
25
  const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
@@ -31,22 +31,40 @@ describe('ncp-app-client-fetch', () => {
31
31
  body: JSON.stringify({ sessionId: 's1' })
32
32
  });
33
33
 
34
- expect(mocks.request).toHaveBeenCalledWith({
34
+ expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:55667/api/ncp/agent/abort', {
35
35
  method: 'POST',
36
- path: '/api/ncp/agent/abort',
37
- body: { sessionId: 's1' }
36
+ headers: {
37
+ accept: 'application/json',
38
+ 'content-type': 'application/json'
39
+ },
40
+ body: JSON.stringify({ sessionId: 's1' }),
41
+ credentials: 'include'
38
42
  });
39
43
  expect(response.ok).toBe(true);
44
+ expect(await response.json()).toEqual({ ok: true });
40
45
  });
41
46
 
42
- it('re-encodes appClient stream events as SSE frames', async () => {
43
- mocks.openStream.mockImplementation(({ onEvent }) => {
44
- onEvent({ name: 'ncp-event', payload: { type: 'message.chunk', payload: { text: 'hello' } } });
45
- return {
46
- finished: Promise.resolve(undefined),
47
- cancel: vi.fn()
48
- };
49
- });
47
+ it('does not synthesize fake HTTP 500 responses for fetch failures', async () => {
48
+ fetchMock.mockRejectedValue(new Error('Failed to fetch'));
49
+ const fetchImpl = createNcpAppClientFetch();
50
+
51
+ await expect(fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
52
+ method: 'POST',
53
+ headers: {
54
+ accept: 'application/json',
55
+ 'content-type': 'application/json'
56
+ },
57
+ body: JSON.stringify({ sessionId: 's1' })
58
+ })).rejects.toThrow('Failed to fetch');
59
+ });
60
+
61
+ it('preserves native SSE request headers', async () => {
62
+ fetchMock.mockResolvedValue(new Response('event: ncp-event\ndata: {"ok":true}\n\n', {
63
+ status: 200,
64
+ headers: {
65
+ 'content-type': 'text/event-stream'
66
+ }
67
+ }));
50
68
  const fetchImpl = createNcpAppClientFetch();
51
69
 
52
70
  const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
@@ -55,15 +73,14 @@ describe('ncp-app-client-fetch', () => {
55
73
  accept: 'text/event-stream'
56
74
  }
57
75
  });
58
- const text = await response.text();
59
76
 
60
- expect(mocks.openStream).toHaveBeenCalledWith({
77
+ expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
61
78
  method: 'GET',
62
- path: '/api/ncp/agent/stream?sessionId=s1',
63
- signal: undefined,
64
- onEvent: expect.any(Function)
79
+ headers: {
80
+ accept: 'text/event-stream'
81
+ },
82
+ credentials: 'include'
65
83
  });
66
- expect(text).toContain('event: ncp-event');
67
- expect(text).toContain('"text":"hello"');
84
+ expect(await response.text()).toContain('event: ncp-event');
68
85
  });
69
86
  });