@nextclaw/ui 0.7.0 → 0.8.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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{ChannelsList-DF2U-LY1.js → ChannelsList-DBcoVJRW.js} +1 -1
  3. package/dist/assets/ChatPage-CD3cxyyM.js +37 -0
  4. package/dist/assets/{DocBrowser-B9ws5JL7.js → DocBrowser-DDX2HMXW.js} +1 -1
  5. package/dist/assets/{LogoBadge-DvGAzkZ3.js → LogoBadge-J53F_3JA.js} +1 -1
  6. package/dist/assets/{MarketplacePage-DG5mHWJ8.js → MarketplacePage-0BZ4bza0.js} +2 -2
  7. package/dist/assets/{ModelConfig-BL_HsOsm.js → ModelConfig-Wzq9wGHV.js} +1 -1
  8. package/dist/assets/{ProvidersList-CH5z00YT.js → ProvidersList-kwzRS8_M.js} +1 -1
  9. package/dist/assets/RuntimeConfig-N771_AM6.js +1 -0
  10. package/dist/assets/{SearchConfig-BhaI0fUf.js → SearchConfig-DVt5QVa_.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CFoimOh9.js → SecretsConfig-CkwauPa8.js} +2 -2
  12. package/dist/assets/SessionsConfig-C3mnHzkZ.js +2 -0
  13. package/dist/assets/{session-run-status-TkIuGbVw.js → chat-message-pxr79GDs.js} +3 -3
  14. package/dist/assets/{index-X5J6Mm--.js → index-BIvFMkN4.js} +1 -1
  15. package/dist/assets/index-CzkY1reu.js +8 -0
  16. package/dist/assets/{index-uMsNsQX6.js → index-GdpEEKnz.js} +1 -1
  17. package/dist/assets/index-RZ0kHHRI.css +1 -0
  18. package/dist/assets/{label-D8ly4a2P.js → label-CmksBHgc.js} +1 -1
  19. package/dist/assets/{page-layout-BSYfvwbp.js → page-layout-Db0GbnhS.js} +1 -1
  20. package/dist/assets/security-config-CjLFME5Q.js +1 -0
  21. package/dist/assets/skeleton-CkpQeVWN.js +1 -0
  22. package/dist/assets/{switch-Ce_g9lpN.js → switch-C24d-UJU.js} +1 -1
  23. package/dist/assets/tabs-custom-D89bh-fc.js +1 -0
  24. package/dist/assets/{useConfirmDialog-A8Ek8Wu7.js → useConfirmDialog-BeP35LcG.js} +2 -2
  25. package/dist/assets/{vendor-B7ozqnFC.js → vendor-psXJBy9u.js} +65 -70
  26. package/dist/index.html +3 -3
  27. package/package.json +5 -2
  28. package/src/api/config.ts +38 -0
  29. package/src/api/types.ts +19 -0
  30. package/src/components/chat/ChatPage.tsx +10 -324
  31. package/src/components/chat/adapters/chat-message.adapter.test.ts +1 -0
  32. package/src/components/chat/chat-chain.test.ts +22 -0
  33. package/src/components/chat/chat-chain.ts +23 -0
  34. package/src/components/chat/chat-page-shell.tsx +103 -0
  35. package/src/components/chat/containers/chat-message-list.container.tsx +5 -1
  36. package/src/components/chat/legacy/LegacyChatPage.tsx +228 -0
  37. package/src/components/chat/ncp/NcpChatPage.tsx +349 -0
  38. package/src/components/chat/ncp/ncp-chat-input.manager.ts +173 -0
  39. package/src/components/chat/ncp/ncp-chat-page-data.ts +134 -0
  40. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +89 -0
  41. package/src/components/chat/ncp/ncp-chat.presenter.ts +33 -0
  42. package/src/components/chat/ncp/ncp-session-adapter.test.ts +49 -0
  43. package/src/components/chat/ncp/ncp-session-adapter.ts +194 -0
  44. package/src/components/chat/presenter/chat-presenter-context.tsx +43 -4
  45. package/src/hooks/useConfig.ts +42 -0
  46. package/src/lib/i18n.ts +1 -1
  47. package/tailwind.config.js +8 -3
  48. package/tsconfig.json +4 -1
  49. package/dist/assets/ChatPage-BX39y0U5.js +0 -36
  50. package/dist/assets/RuntimeConfig-BplBgkwo.js +0 -1
  51. package/dist/assets/SessionsConfig-BHTAYn9T.js +0 -2
  52. package/dist/assets/index-BLeJkJ0o.css +0 -1
  53. package/dist/assets/index-DK4TS5ev.js +0 -8
  54. package/dist/assets/security-config-DlKEYHNN.js +0 -1
  55. package/dist/assets/skeleton-CWbsNx2h.js +0 -1
  56. package/dist/assets/tabs-custom-Cf5azvT5.js +0 -1
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-DK4TS5ev.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-B7ozqnFC.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-BLeJkJ0o.css">
9
+ <script type="module" crossorigin src="/assets/index-CzkY1reu.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-psXJBy9u.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-RZ0kHHRI.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,10 @@
27
27
  "tailwind-merge": "^2.5.4",
28
28
  "zod": "^3.23.8",
29
29
  "zustand": "^5.0.2",
30
- "@nextclaw/agent-chat-ui": "0.1.1",
30
+ "@nextclaw/ncp-http-agent-client": "0.3.0",
31
+ "@nextclaw/ncp-react": "0.3.0",
32
+ "@nextclaw/agent-chat-ui": "0.2.0",
33
+ "@nextclaw/ncp": "0.3.0",
31
34
  "@nextclaw/agent-chat": "0.1.1"
32
35
  },
33
36
  "devDependencies": {
package/src/api/config.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
24
  ProviderCreateRequest,
25
25
  ProviderCreateResult,
26
26
  ProviderDeleteResult,
27
+ NcpSessionMessagesView,
28
+ NcpSessionsListView,
27
29
  RuntimeConfigUpdate,
28
30
  SecretsConfigUpdate,
29
31
  SecretsView,
@@ -369,6 +371,42 @@ export async function deleteSession(key: string): Promise<{ deleted: boolean }>
369
371
  return response.data;
370
372
  }
371
373
 
374
+ // GET /api/ncp/sessions
375
+ export async function fetchNcpSessions(params?: { limit?: number }): Promise<NcpSessionsListView> {
376
+ const query = new URLSearchParams();
377
+ if (typeof params?.limit === 'number' && Number.isFinite(params.limit)) {
378
+ query.set('limit', String(Math.max(1, Math.trunc(params.limit))));
379
+ }
380
+ const suffix = query.toString();
381
+ const response = await api.get<NcpSessionsListView>(suffix ? `/api/ncp/sessions?${suffix}` : '/api/ncp/sessions');
382
+ if (!response.ok) {
383
+ throw new Error(response.error.message);
384
+ }
385
+ return response.data;
386
+ }
387
+
388
+ // GET /api/ncp/sessions/:sessionId/messages
389
+ export async function fetchNcpSessionMessages(sessionId: string, limit = 200): Promise<NcpSessionMessagesView> {
390
+ const response = await api.get<NcpSessionMessagesView>(
391
+ `/api/ncp/sessions/${encodeURIComponent(sessionId)}/messages?limit=${Math.max(1, Math.trunc(limit))}`
392
+ );
393
+ if (!response.ok) {
394
+ throw new Error(response.error.message);
395
+ }
396
+ return response.data;
397
+ }
398
+
399
+ // DELETE /api/ncp/sessions/:sessionId
400
+ export async function deleteNcpSession(sessionId: string): Promise<{ deleted: boolean; sessionId: string }> {
401
+ const response = await api.delete<{ deleted: boolean; sessionId: string }>(
402
+ `/api/ncp/sessions/${encodeURIComponent(sessionId)}`
403
+ );
404
+ if (!response.ok) {
405
+ throw new Error(response.error.message);
406
+ }
407
+ return response.data;
408
+ }
409
+
372
410
  // POST /api/chat/turn
373
411
  export async function sendChatTurn(data: ChatTurnRequest): Promise<ChatTurnView> {
374
412
  const response = await api.post<ChatTurnView>('/api/chat/turn', data);
package/src/api/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { NcpMessage, NcpSessionStatus, NcpSessionSummary } from '@nextclaw/ncp';
2
+
1
3
  // API Types - matching backend response format
2
4
  export type ApiError = {
3
5
  code: string;
@@ -261,6 +263,23 @@ export type SessionHistoryView = {
261
263
  events: SessionEventView[];
262
264
  };
263
265
 
266
+ export type NcpSessionSummaryView = NcpSessionSummary;
267
+
268
+ export type NcpSessionsListView = {
269
+ sessions: NcpSessionSummaryView[];
270
+ total: number;
271
+ };
272
+
273
+ export type NcpMessageView = NcpMessage;
274
+
275
+ export type NcpSessionMessagesView = {
276
+ sessionId: string;
277
+ messages: NcpMessageView[];
278
+ total: number;
279
+ };
280
+
281
+ export type NcpSessionStatusView = NcpSessionStatus;
282
+
264
283
  export type SessionPatchUpdate = {
265
284
  label?: string | null;
266
285
  preferredModel?: string | null;
@@ -1,330 +1,16 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
2
- import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
- import { useConfirmDialog } from '@/hooks/useConfirmDialog';
4
- import { ChatSidebar } from '@/components/chat/ChatSidebar';
5
- import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
6
- import { CronConfig } from '@/components/config/CronConfig';
7
- import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
8
- import { useSessionRunStatus } from '@/components/chat/chat-page-runtime';
9
- import { useChatRuntimeController } from '@/components/chat/useChatRuntimeController';
10
- import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
11
- import { useChatPageData, sessionDisplayName } from '@/components/chat/chat-page-data';
12
- import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
13
- import { ChatPresenter } from '@/components/chat/presenter/chat.presenter';
14
- import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
15
- import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
16
- import { useLocation, useNavigate, useParams } from 'react-router-dom';
17
-
18
- type MainPanelView = 'chat' | 'cron' | 'skills';
19
-
20
- type ChatPageProps = {
21
- view: MainPanelView;
22
- };
23
-
24
- type UseSessionSyncParams = {
25
- view: MainPanelView;
26
- routeSessionKey: string | null;
27
- selectedSessionKey: string | null;
28
- selectedAgentId: string;
29
- setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
30
- setSelectedAgentId: Dispatch<SetStateAction<string>>;
31
- selectedSessionKeyRef: MutableRefObject<string | null>;
32
- resetStreamState: () => void;
33
- };
34
-
35
- function useChatSessionSync(params: UseSessionSyncParams): void {
36
- const {
37
- view,
38
- routeSessionKey,
39
- selectedSessionKey,
40
- selectedAgentId,
41
- setSelectedSessionKey,
42
- setSelectedAgentId,
43
- selectedSessionKeyRef,
44
- resetStreamState
45
- } = params;
46
-
47
- useEffect(() => {
48
- if (view !== 'chat') {
49
- return;
50
- }
51
- if (routeSessionKey) {
52
- if (selectedSessionKey !== routeSessionKey) {
53
- setSelectedSessionKey(routeSessionKey);
54
- }
55
- return;
56
- }
57
- if (selectedSessionKey !== null) {
58
- setSelectedSessionKey(null);
59
- resetStreamState();
60
- }
61
- }, [resetStreamState, routeSessionKey, selectedSessionKey, setSelectedSessionKey, view]);
62
-
63
- useEffect(() => {
64
- const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
65
- if (!inferred) {
66
- return;
67
- }
68
- if (selectedAgentId !== inferred) {
69
- setSelectedAgentId(inferred);
70
- }
71
- }, [selectedAgentId, selectedSessionKey, setSelectedAgentId]);
72
-
73
- useEffect(() => {
74
- selectedSessionKeyRef.current = selectedSessionKey;
75
- }, [selectedSessionKey, selectedSessionKeyRef]);
76
- }
77
-
78
- type ChatPageLayoutProps = {
79
- view: MainPanelView;
80
- confirmDialog: JSX.Element;
81
- };
82
-
83
- function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
84
- return (
85
- <div className="h-full flex">
86
- <ChatSidebar />
87
-
88
- {view === 'chat' ? (
89
- <ChatConversationPanel />
90
- ) : (
91
- <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
92
- {view === 'cron' ? (
93
- <div className="h-full overflow-auto custom-scrollbar">
94
- <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
95
- <CronConfig />
96
- </div>
97
- </div>
98
- ) : (
99
- <div className="h-full overflow-hidden">
100
- <div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
101
- <MarketplacePage forcedType="skills" />
102
- </div>
103
- </div>
104
- )}
105
- </section>
106
- )}
107
-
108
- {confirmDialog}
109
- </div>
110
- );
111
- }
1
+ import { useLocation } from 'react-router-dom';
2
+ import { resolveChatChain } from '@/components/chat/chat-chain';
3
+ import type { ChatPageProps } from '@/components/chat/chat-page-shell';
4
+ import { LegacyChatPage } from '@/components/chat/legacy/LegacyChatPage';
5
+ import { NcpChatPage } from '@/components/chat/ncp/NcpChatPage';
112
6
 
113
7
  export function ChatPage({ view }: ChatPageProps) {
114
- const [presenter] = useState(() => new ChatPresenter());
115
- const query = useChatSessionListStore((state) => state.snapshot.query);
116
- const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
117
- const selectedAgentId = useChatSessionListStore((state) => state.snapshot.selectedAgentId);
118
- const pendingSessionType = useChatInputStore((state) => state.snapshot.pendingSessionType);
119
- const { confirm, ConfirmDialog } = useConfirmDialog();
120
8
  const location = useLocation();
121
- const navigate = useNavigate();
122
- const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
123
- const threadRef = useRef<HTMLDivElement | null>(null);
124
- const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
125
- const thinkingHydratedSessionKeyRef = useRef<string | null>(null);
126
- const routeSessionKey = useMemo(
127
- () => parseSessionKeyFromRoute(routeSessionIdParam),
128
- [routeSessionIdParam]
129
- );
130
- const {
131
- sessionsQuery,
132
- installedSkillsQuery,
133
- chatCapabilitiesQuery,
134
- historyQuery,
135
- isProviderStateResolved,
136
- modelOptions,
137
- sessions,
138
- skillRecords,
139
- selectedSession,
140
- historyMessages,
141
- selectedSessionThinkingLevel,
142
- sessionTypeOptions,
143
- defaultSessionType,
144
- selectedSessionType,
145
- canEditSessionType,
146
- sessionTypeUnavailable,
147
- sessionTypeUnavailableMessage
148
- } = useChatPageData({
149
- query,
150
- selectedSessionKey,
151
- selectedAgentId,
152
- pendingSessionType,
153
- setPendingSessionType: presenter.chatInputManager.setPendingSessionType,
154
- setSelectedModel: presenter.chatInputManager.setSelectedModel
155
- });
156
- const {
157
- uiMessages,
158
- isSending,
159
- isAwaitingAssistantOutput,
160
- canStopCurrentRun,
161
- stopDisabledReason,
162
- lastSendError,
163
- activeBackendRunId,
164
- sendMessage,
165
- stopCurrentRun,
166
- resumeRun,
167
- resetStreamState,
168
- applyHistoryMessages
169
- } = useChatRuntimeController(
170
- {
171
- selectedSessionKeyRef,
172
- setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
173
- setDraft: presenter.chatInputManager.setDraft,
174
- refetchSessions: sessionsQuery.refetch,
175
- refetchHistory: historyQuery.refetch
176
- },
177
- presenter.chatController
178
- );
179
-
180
- console.log('[ChatPage] uiMessages', { uiMessages, historyMessages });
181
- useEffect(() => {
182
- presenter.chatStreamActionsManager.bind({
183
- sendMessage,
184
- stopCurrentRun,
185
- resumeRun,
186
- resetStreamState,
187
- applyHistoryMessages
188
- });
189
- }, [applyHistoryMessages, presenter, resetStreamState, resumeRun, sendMessage, stopCurrentRun]);
190
-
191
- const { sessionRunStatusByKey } = useSessionRunStatus({
192
- view,
193
- selectedSessionKey,
194
- activeBackendRunId,
195
- isLocallyRunning: isSending || Boolean(activeBackendRunId),
196
- resumeRun: presenter.chatStreamActionsManager.resumeRun
197
- });
198
-
199
- useChatSessionSync({
200
- view,
201
- routeSessionKey,
202
- selectedSessionKey,
203
- selectedAgentId,
204
- setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
205
- setSelectedAgentId: presenter.chatSessionListManager.setSelectedAgentId,
206
- selectedSessionKeyRef,
207
- resetStreamState: presenter.chatStreamActionsManager.resetStreamState
208
- });
209
-
210
- useEffect(() => {
211
- presenter.chatStreamActionsManager.applyHistoryMessages(historyMessages, {
212
- isLoading: historyQuery.isLoading
213
- });
214
- }, [historyMessages, historyQuery.isLoading, presenter]);
215
-
216
- useEffect(() => {
217
- presenter.chatUiManager.syncState({
218
- pathname: location.pathname
219
- });
220
- presenter.chatUiManager.bindActions({
221
- navigate,
222
- confirm
223
- });
224
- }, [confirm, location.pathname, navigate, presenter]);
225
- const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
226
-
227
- useEffect(() => {
228
- presenter.chatThreadManager.bindActions({
229
- refetchSessions: sessionsQuery.refetch
230
- });
231
- }, [
232
- presenter,
233
- sessionsQuery.refetch,
234
- ]);
235
-
236
- useEffect(() => {
237
- const shouldHydrateThinkingFromHistory =
238
- !isSending &&
239
- !isAwaitingAssistantOutput &&
240
- !historyQuery.isLoading &&
241
- selectedSessionKey !== thinkingHydratedSessionKeyRef.current;
9
+ const chatChain = resolveChatChain(location.search);
242
10
 
243
- presenter.chatInputManager.syncSnapshot({
244
- isProviderStateResolved,
245
- defaultSessionType,
246
- canStopGeneration: canStopCurrentRun,
247
- stopDisabledReason,
248
- stopSupported: chatCapabilitiesQuery.data?.stopSupported ?? false,
249
- stopReason: chatCapabilitiesQuery.data?.stopReason,
250
- sendError: lastSendError,
251
- isSending,
252
- modelOptions,
253
- sessionTypeOptions,
254
- selectedSessionType,
255
- ...(shouldHydrateThinkingFromHistory ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
256
- canEditSessionType,
257
- sessionTypeUnavailable,
258
- skillRecords,
259
- isSkillsLoading: installedSkillsQuery.isLoading
260
- });
261
- if (shouldHydrateThinkingFromHistory) {
262
- thinkingHydratedSessionKeyRef.current = selectedSessionKey;
263
- }
264
- if (!selectedSessionKey) {
265
- thinkingHydratedSessionKeyRef.current = null;
266
- }
267
- presenter.chatSessionListManager.syncSnapshot({
268
- sessions,
269
- query,
270
- isLoading: sessionsQuery.isLoading
271
- });
272
- presenter.chatRunStatusManager.syncSnapshot({
273
- sessionRunStatusByKey,
274
- isLocallyRunning: isSending || Boolean(activeBackendRunId),
275
- activeBackendRunId
276
- });
277
- presenter.chatThreadManager.syncSnapshot({
278
- isProviderStateResolved,
279
- modelOptions,
280
- sessionTypeUnavailable,
281
- sessionTypeUnavailableMessage,
282
- selectedSessionKey,
283
- sessionDisplayName: currentSessionDisplayName,
284
- canDeleteSession: Boolean(selectedSession),
285
- threadRef,
286
- isHistoryLoading: historyQuery.isLoading,
287
- uiMessages,
288
- isSending,
289
- isAwaitingAssistantOutput
290
- });
291
- }, [
292
- activeBackendRunId,
293
- canEditSessionType,
294
- canStopCurrentRun,
295
- currentSessionDisplayName,
296
- chatCapabilitiesQuery.data?.stopReason,
297
- chatCapabilitiesQuery.data?.stopSupported,
298
- defaultSessionType,
299
- historyQuery.isLoading,
300
- installedSkillsQuery.isLoading,
301
- isAwaitingAssistantOutput,
302
- isProviderStateResolved,
303
- isSending,
304
- lastSendError,
305
- uiMessages,
306
- modelOptions,
307
- presenter,
308
- query,
309
- selectedSession,
310
- selectedSessionThinkingLevel,
311
- selectedSessionKey,
312
- selectedAgentId,
313
- selectedSessionType,
314
- sessionRunStatusByKey,
315
- sessionTypeOptions,
316
- sessionTypeUnavailable,
317
- sessionTypeUnavailableMessage,
318
- sessions,
319
- sessionsQuery.isLoading,
320
- stopDisabledReason,
321
- threadRef,
322
- skillRecords
323
- ]);
11
+ if (chatChain === 'ncp') {
12
+ return <NcpChatPage view={view} />;
13
+ }
324
14
 
325
- return (
326
- <ChatPresenterProvider presenter={presenter}>
327
- <ChatPageLayout view={view} confirmDialog={<ConfirmDialog />} />
328
- </ChatPresenterProvider>
329
- );
15
+ return <LegacyChatPage view={view} />;
330
16
  }
@@ -61,6 +61,7 @@ describe('adaptChatMessages', () => {
61
61
  expect(adapted[0]?.roleLabel).toBe('Assistant');
62
62
  expect(adapted[0]?.timestampLabel).toBe('formatted:2026-03-17T10:00:00.000Z');
63
63
  expect(adapted[0]?.parts.map((part) => part.type)).toEqual(['markdown', 'reasoning', 'tool-card']);
64
+ expect(adapted[0]?.parts[1]).toMatchObject({ type: 'reasoning', label: 'Reasoning', text: 'internal reasoning' });
64
65
  expect(adapted[0]?.parts[2]).toMatchObject({
65
66
  type: 'tool-card',
66
67
  card: {
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { resolveChatChain } from '@/components/chat/chat-chain';
3
+
4
+ describe('resolveChatChain', () => {
5
+ it('defaults to ncp when no query or env override is provided', () => {
6
+ vi.stubEnv('VITE_CHAT_CHAIN', '');
7
+
8
+ expect(resolveChatChain('')).toBe('ncp');
9
+ });
10
+
11
+ it('allows explicit legacy rollback from query string', () => {
12
+ vi.stubEnv('VITE_CHAT_CHAIN', 'ncp');
13
+
14
+ expect(resolveChatChain('?chatChain=legacy')).toBe('legacy');
15
+ });
16
+
17
+ it('accepts env override when query string is absent', () => {
18
+ vi.stubEnv('VITE_CHAT_CHAIN', 'legacy');
19
+
20
+ expect(resolveChatChain('')).toBe('legacy');
21
+ });
22
+ });
@@ -0,0 +1,23 @@
1
+ export type ChatChain = 'legacy' | 'ncp';
2
+
3
+ const DEFAULT_CHAT_CHAIN: ChatChain = 'ncp';
4
+
5
+ function normalizeChatChain(value: string | null | undefined): ChatChain | null {
6
+ if (typeof value !== 'string') {
7
+ return null;
8
+ }
9
+ const normalized = value.trim().toLowerCase();
10
+ if (normalized === 'legacy' || normalized === 'ncp') {
11
+ return normalized;
12
+ }
13
+ return null;
14
+ }
15
+
16
+ export function resolveChatChain(search: string): ChatChain {
17
+ const fromSearch = normalizeChatChain(new URLSearchParams(search).get('chatChain'));
18
+ if (fromSearch) {
19
+ return fromSearch;
20
+ }
21
+ const fromEnv = normalizeChatChain(import.meta.env.VITE_CHAT_CHAIN);
22
+ return fromEnv ?? DEFAULT_CHAT_CHAIN;
23
+ }
@@ -0,0 +1,103 @@
1
+ import { useEffect } from 'react';
2
+ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
+ import { ChatSidebar } from '@/components/chat/ChatSidebar';
4
+ import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
5
+ import { CronConfig } from '@/components/config/CronConfig';
6
+ import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
7
+
8
+ export type MainPanelView = 'chat' | 'cron' | 'skills';
9
+
10
+ export type ChatPageProps = {
11
+ view: MainPanelView;
12
+ };
13
+
14
+ type UseChatSessionSyncParams = {
15
+ view: MainPanelView;
16
+ routeSessionKey: string | null;
17
+ selectedSessionKey: string | null;
18
+ selectedAgentId: string;
19
+ setSelectedSessionKey: Dispatch<SetStateAction<string | null>>;
20
+ setSelectedAgentId: Dispatch<SetStateAction<string>>;
21
+ selectedSessionKeyRef: MutableRefObject<string | null>;
22
+ resetStreamState: () => void;
23
+ resolveAgentIdFromSessionKey: (sessionKey: string) => string | null;
24
+ };
25
+
26
+ export function useChatSessionSync(params: UseChatSessionSyncParams): void {
27
+ const {
28
+ view,
29
+ routeSessionKey,
30
+ selectedSessionKey,
31
+ selectedAgentId,
32
+ setSelectedSessionKey,
33
+ setSelectedAgentId,
34
+ selectedSessionKeyRef,
35
+ resetStreamState,
36
+ resolveAgentIdFromSessionKey
37
+ } = params;
38
+
39
+ useEffect(() => {
40
+ if (view !== 'chat') {
41
+ return;
42
+ }
43
+ if (routeSessionKey) {
44
+ if (selectedSessionKey !== routeSessionKey) {
45
+ setSelectedSessionKey(routeSessionKey);
46
+ }
47
+ return;
48
+ }
49
+ if (selectedSessionKey !== null) {
50
+ setSelectedSessionKey(null);
51
+ resetStreamState();
52
+ }
53
+ }, [resetStreamState, routeSessionKey, selectedSessionKey, setSelectedSessionKey, view]);
54
+
55
+ useEffect(() => {
56
+ const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
57
+ if (!inferred) {
58
+ return;
59
+ }
60
+ if (selectedAgentId !== inferred) {
61
+ setSelectedAgentId(inferred);
62
+ }
63
+ }, [resolveAgentIdFromSessionKey, selectedAgentId, selectedSessionKey, setSelectedAgentId]);
64
+
65
+ useEffect(() => {
66
+ selectedSessionKeyRef.current = selectedSessionKey;
67
+ }, [selectedSessionKey, selectedSessionKeyRef]);
68
+ }
69
+
70
+ type ChatPageLayoutProps = {
71
+ view: MainPanelView;
72
+ confirmDialog: JSX.Element;
73
+ };
74
+
75
+ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
76
+ return (
77
+ <div className="h-full flex">
78
+ <ChatSidebar />
79
+
80
+ {view === 'chat' ? (
81
+ <ChatConversationPanel />
82
+ ) : (
83
+ <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
84
+ {view === 'cron' ? (
85
+ <div className="h-full overflow-auto custom-scrollbar">
86
+ <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
87
+ <CronConfig />
88
+ </div>
89
+ </div>
90
+ ) : (
91
+ <div className="h-full overflow-hidden">
92
+ <div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
93
+ <MarketplacePage forcedType="skills" />
94
+ </div>
95
+ </div>
96
+ )}
97
+ </section>
98
+ )}
99
+
100
+ {confirmDialog}
101
+ </div>
102
+ );
103
+ }
@@ -55,7 +55,11 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
55
55
  <ChatMessageList
56
56
  messages={messages}
57
57
  isSending={props.isSending}
58
- hasStreamingDraft={props.uiMessages.some((message) => message.meta?.status === 'streaming')}
58
+ hasAssistantDraft={props.uiMessages.some(
59
+ (message) =>
60
+ message.role === 'assistant' &&
61
+ (message.meta?.status === 'streaming' || message.meta?.status === 'pending')
62
+ )}
59
63
  className={props.className}
60
64
  texts={{
61
65
  copyCodeLabel: t('chatCodeCopy'),