@nextclaw/ui 0.8.0 → 0.9.1

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 (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/ChannelsList-DhvjpZcs.js +1 -0
  3. package/dist/assets/ChatPage-B8VBaMQm.js +38 -0
  4. package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-LpzGe8An.js} +1 -1
  5. package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-Be4lktJN.js} +1 -1
  6. package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-Cx9AI3_h.js} +3 -3
  7. package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-DuImUHIX.js} +1 -1
  8. package/dist/assets/ProvidersList-Ccleg25k.js +1 -0
  9. package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-C6iqpJR_.js} +1 -1
  10. package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-Dvp1TAXu.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-D5Ymlvt9.js} +1 -1
  12. package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-CIA_jA1P.js} +2 -2
  13. package/dist/assets/{chat-message-pxr79GDs.js → chat-message-B60Fh9kI.js} +1 -1
  14. package/dist/assets/index-BiPDnzv0.js +8 -0
  15. package/dist/assets/index-C8GsgIUn.css +1 -0
  16. package/dist/assets/{index-GdpEEKnz.js → index-CPDASUXh.js} +1 -1
  17. package/dist/assets/{label-CmksBHgc.js → label-D4fGx6Wb.js} +1 -1
  18. package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-twy8gmBE.js} +1 -1
  19. package/dist/assets/popover-DYbYpt1j.js +1 -0
  20. package/dist/assets/{security-config-CjLFME5Q.js → security-config-BcIZ4rpb.js} +1 -1
  21. package/dist/assets/skeleton-DypBy7jp.js +1 -0
  22. package/dist/assets/{switch-C24d-UJU.js → switch-DqA6r5XR.js} +1 -1
  23. package/dist/assets/tabs-custom-C6enKKs1.js +1 -0
  24. package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-CHBf5Of7.js} +1 -1
  25. package/dist/assets/{vendor-psXJBy9u.js → vendor-DKBNiC31.js} +1 -1
  26. package/dist/index.html +3 -3
  27. package/package.json +6 -6
  28. package/src/api/config.ts +9 -38
  29. package/src/api/ncp-session.ts +50 -0
  30. package/src/api/types.ts +1 -0
  31. package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
  32. package/src/components/chat/ChatConversationPanel.tsx +21 -12
  33. package/src/components/chat/ChatSidebar.test.tsx +203 -0
  34. package/src/components/chat/ChatSidebar.tsx +97 -7
  35. package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -82
  36. package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
  37. package/src/components/chat/chat-composer-state.ts +53 -0
  38. package/src/components/chat/chat-page-data.ts +30 -1
  39. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  40. package/src/components/chat/chat-page-runtime.ts +101 -15
  41. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  42. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  43. package/src/components/chat/chat-stream/types.ts +3 -0
  44. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -63
  45. package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
  46. package/src/components/chat/legacy/LegacyChatPage.tsx +25 -0
  47. package/src/components/chat/managers/chat-input.manager.ts +48 -13
  48. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  49. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  50. package/src/components/chat/ncp/NcpChatPage.tsx +53 -13
  51. package/src/components/chat/ncp/ncp-chat-input.manager.ts +48 -12
  52. package/src/components/chat/ncp/ncp-chat-page-data.ts +34 -2
  53. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +1 -1
  54. package/src/components/chat/ncp/ncp-session-adapter.test.ts +27 -1
  55. package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
  56. package/src/components/chat/presenter/chat-presenter-context.tsx +2 -0
  57. package/src/components/chat/stores/chat-input.store.ts +4 -0
  58. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  59. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  60. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  61. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  62. package/src/hooks/useConfig.ts +2 -4
  63. package/src/hooks/useMarketplace.ts +7 -4
  64. package/src/hooks/useWebSocket.ts +23 -2
  65. package/dist/assets/ChannelsList-DBcoVJRW.js +0 -1
  66. package/dist/assets/ChatPage-CD3cxyyM.js +0 -37
  67. package/dist/assets/ProvidersList-kwzRS8_M.js +0 -1
  68. package/dist/assets/index-BIvFMkN4.js +0 -1
  69. package/dist/assets/index-CzkY1reu.js +0 -8
  70. package/dist/assets/index-RZ0kHHRI.css +0 -1
  71. package/dist/assets/skeleton-CkpQeVWN.js +0 -1
  72. package/dist/assets/tabs-custom-D89bh-fc.js +0 -1
@@ -1,9 +1,12 @@
1
- import { useMemo } from 'react';
2
- import { type UiMessage } from '@nextclaw/agent-chat';
3
- import { ChatMessageList } from '@nextclaw/agent-chat-ui';
4
- import { adaptChatMessages, type ChatMessageSource } from '@/components/chat/adapters/chat-message.adapter';
5
- import { useI18n } from '@/components/providers/I18nProvider';
6
- import { formatDateTime, t } from '@/lib/i18n';
1
+ import { useMemo } from "react";
2
+ import { type UiMessage } from "@nextclaw/agent-chat";
3
+ import { ChatMessageList } from "@nextclaw/agent-chat-ui";
4
+ import {
5
+ adaptChatMessages,
6
+ type ChatMessageSource,
7
+ } from "@/components/chat/adapters/chat-message.adapter";
8
+ import { useI18n } from "@/components/providers/I18nProvider";
9
+ import { formatDateTime, t } from "@/lib/i18n";
7
10
 
8
11
  type ChatMessageListContainerProps = {
9
12
  uiMessages: UiMessage[];
@@ -20,11 +23,11 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
20
23
  role: message.role,
21
24
  meta: {
22
25
  timestamp: message.meta?.timestamp,
23
- status: message.meta?.status
26
+ status: message.meta?.status,
24
27
  },
25
- parts: message.parts as unknown as ChatMessageSource['parts']
28
+ parts: message.parts as unknown as ChatMessageSource["parts"],
26
29
  })),
27
- [props.uiMessages]
30
+ [props.uiMessages],
28
31
  );
29
32
 
30
33
  const messages = useMemo(
@@ -34,21 +37,21 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
34
37
  formatTimestamp: (value) => formatDateTime(value, language),
35
38
  texts: {
36
39
  roleLabels: {
37
- user: t('chatRoleUser'),
38
- assistant: t('chatRoleAssistant'),
39
- tool: t('chatRoleTool'),
40
- system: t('chatRoleSystem'),
41
- fallback: t('chatRoleMessage')
40
+ user: t("chatRoleUser"),
41
+ assistant: t("chatRoleAssistant"),
42
+ tool: t("chatRoleTool"),
43
+ system: t("chatRoleSystem"),
44
+ fallback: t("chatRoleMessage"),
42
45
  },
43
- reasoningLabel: t('chatReasoning'),
44
- toolCallLabel: t('chatToolCall'),
45
- toolResultLabel: t('chatToolResult'),
46
- toolNoOutputLabel: t('chatToolNoOutput'),
47
- toolOutputLabel: t('chatToolOutput'),
48
- unknownPartLabel: t('chatUnknownPart')
49
- }
46
+ reasoningLabel: t("chatReasoning"),
47
+ toolCallLabel: t("chatToolCall"),
48
+ toolResultLabel: t("chatToolResult"),
49
+ toolNoOutputLabel: t("chatToolNoOutput"),
50
+ toolOutputLabel: t("chatToolOutput"),
51
+ unknownPartLabel: t("chatUnknownPart"),
52
+ },
50
53
  }),
51
- [language, sourceMessages]
54
+ [language, sourceMessages],
52
55
  );
53
56
 
54
57
  return (
@@ -57,14 +60,15 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
57
60
  isSending={props.isSending}
58
61
  hasAssistantDraft={props.uiMessages.some(
59
62
  (message) =>
60
- message.role === 'assistant' &&
61
- (message.meta?.status === 'streaming' || message.meta?.status === 'pending')
63
+ message.role === "assistant" &&
64
+ (message.meta?.status === "streaming" ||
65
+ message.meta?.status === "pending"),
62
66
  )}
63
67
  className={props.className}
64
68
  texts={{
65
- copyCodeLabel: t('chatCodeCopy'),
66
- copiedCodeLabel: t('chatCodeCopied'),
67
- typingLabel: t('chatTyping')
69
+ copyCodeLabel: t("chatCodeCopy"),
70
+ copiedCodeLabel: t("chatCodeCopied"),
71
+ typingLabel: t("chatTyping"),
68
72
  }}
69
73
  />
70
74
  );
@@ -8,6 +8,7 @@ import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/compon
8
8
  import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
9
9
  import { ChatPresenter } from '@/components/chat/presenter/chat.presenter';
10
10
  import { useChatRuntimeController } from '@/components/chat/useChatRuntimeController';
11
+ import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
11
12
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
12
13
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
13
14
 
@@ -23,6 +24,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
23
24
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
24
25
  const threadRef = useRef<HTMLDivElement | null>(null);
25
26
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
27
+ const modelHydratedSessionKeyRef = useRef<string | null>(null);
26
28
  const thinkingHydratedSessionKeyRef = useRef<string | null>(null);
27
29
  const routeSessionKey = useMemo(
28
30
  () => parseSessionKeyFromRoute(routeSessionIdParam),
@@ -38,6 +40,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
38
40
  sessions,
39
41
  skillRecords,
40
42
  selectedSession,
43
+ hydratedSessionModel,
41
44
  historyMessages,
42
45
  selectedSessionThinkingLevel,
43
46
  sessionTypeOptions,
@@ -72,6 +75,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
72
75
  selectedSessionKeyRef,
73
76
  setSelectedSessionKey: presenter.chatSessionListManager.setSelectedSessionKey,
74
77
  setDraft: presenter.chatInputManager.setDraft,
78
+ setComposerNodes: presenter.chatInputManager.setComposerNodes,
75
79
  refetchSessions: sessionsQuery.refetch,
76
80
  refetchHistory: historyQuery.refetch
77
81
  },
@@ -125,6 +129,9 @@ export function LegacyChatPage({ view }: ChatPageProps) {
125
129
  }, [confirm, location.pathname, navigate, presenter]);
126
130
 
127
131
  const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
132
+ const currentSessionTypeLabel =
133
+ sessionTypeOptions.find((option) => option.value === selectedSessionType)?.label ??
134
+ resolveSessionTypeLabel(selectedSessionType);
128
135
 
129
136
  useEffect(() => {
130
137
  presenter.chatThreadManager.bindActions({
@@ -133,10 +140,19 @@ export function LegacyChatPage({ view }: ChatPageProps) {
133
140
  }, [presenter, sessionsQuery.refetch]);
134
141
 
135
142
  useEffect(() => {
143
+ const shouldHydrateModelFromSession =
144
+ !isSending &&
145
+ !isAwaitingAssistantOutput &&
146
+ !sessionsQuery.isLoading &&
147
+ isProviderStateResolved &&
148
+ modelOptions.length > 0 &&
149
+ selectedSessionKey !== modelHydratedSessionKeyRef.current;
136
150
  const shouldHydrateThinkingFromHistory =
137
151
  !isSending &&
138
152
  !isAwaitingAssistantOutput &&
139
153
  !historyQuery.isLoading &&
154
+ isProviderStateResolved &&
155
+ modelOptions.length > 0 &&
140
156
  selectedSessionKey !== thinkingHydratedSessionKeyRef.current;
141
157
 
142
158
  presenter.chatInputManager.syncSnapshot({
@@ -149,6 +165,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
149
165
  sendError: lastSendError,
150
166
  isSending,
151
167
  modelOptions,
168
+ ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
152
169
  sessionTypeOptions,
153
170
  selectedSessionType,
154
171
  ...(shouldHydrateThinkingFromHistory ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -157,10 +174,14 @@ export function LegacyChatPage({ view }: ChatPageProps) {
157
174
  skillRecords,
158
175
  isSkillsLoading: installedSkillsQuery.isLoading
159
176
  });
177
+ if (shouldHydrateModelFromSession) {
178
+ modelHydratedSessionKeyRef.current = selectedSessionKey;
179
+ }
160
180
  if (shouldHydrateThinkingFromHistory) {
161
181
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
162
182
  }
163
183
  if (!selectedSessionKey) {
184
+ modelHydratedSessionKeyRef.current = null;
164
185
  thinkingHydratedSessionKeyRef.current = null;
165
186
  }
166
187
  presenter.chatSessionListManager.syncSnapshot({
@@ -178,6 +199,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
178
199
  modelOptions,
179
200
  sessionTypeUnavailable,
180
201
  sessionTypeUnavailableMessage,
202
+ sessionTypeLabel: currentSessionTypeLabel,
181
203
  selectedSessionKey,
182
204
  sessionDisplayName: currentSessionDisplayName,
183
205
  canDeleteSession: Boolean(selectedSession),
@@ -192,15 +214,18 @@ export function LegacyChatPage({ view }: ChatPageProps) {
192
214
  canEditSessionType,
193
215
  canStopCurrentRun,
194
216
  currentSessionDisplayName,
217
+ currentSessionTypeLabel,
195
218
  chatCapabilitiesQuery.data?.stopReason,
196
219
  chatCapabilitiesQuery.data?.stopSupported,
197
220
  defaultSessionType,
198
221
  historyQuery.isLoading,
199
222
  installedSkillsQuery.isLoading,
200
223
  isAwaitingAssistantOutput,
224
+ hydratedSessionModel,
201
225
  isProviderStateResolved,
202
226
  isSending,
203
227
  lastSendError,
228
+ modelOptions.length,
204
229
  modelOptions,
205
230
  presenter,
206
231
  query,
@@ -1,3 +1,11 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ createInitialChatComposerNodes,
4
+ createChatComposerNodesFromDraft,
5
+ deriveChatComposerDraft,
6
+ deriveSelectedSkillsFromComposer,
7
+ syncComposerSkills
8
+ } from '@/components/chat/chat-composer-state';
1
9
  import { updateSession } from '@/api/config';
2
10
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
11
  import { buildNewSessionKey } from '@/components/chat/chat-session-route';
@@ -5,12 +13,15 @@ import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
5
13
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
6
14
  import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
7
15
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
16
+ import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
8
17
  import type { SetStateAction } from 'react';
9
18
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
10
19
  import type { ThinkingLevel } from '@/api/types';
11
20
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
12
21
 
13
22
  export class ChatInputManager {
23
+ private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateSession);
24
+
14
25
  constructor(
15
26
  private uiManager: ChatUiManager,
16
27
  private streamActionsManager: ChatStreamActionsManager
@@ -33,6 +44,17 @@ export class ChatInputManager {
33
44
  return next;
34
45
  };
35
46
 
47
+ private isSameStringArray = (left: string[], right: string[]): boolean =>
48
+ left.length === right.length && left.every((value, index) => value === right[index]);
49
+
50
+ private syncComposerSnapshot = (nodes: ChatComposerNode[]) => {
51
+ useChatInputStore.getState().setSnapshot({
52
+ composerNodes: nodes,
53
+ draft: deriveChatComposerDraft(nodes),
54
+ selectedSkills: deriveSelectedSkillsFromComposer(nodes)
55
+ });
56
+ };
57
+
36
58
  syncSnapshot = (patch: Partial<ChatInputSnapshot>) => {
37
59
  if (!this.hasSnapshotChanges(patch)) {
38
60
  return;
@@ -43,8 +65,8 @@ export class ChatInputManager {
43
65
  Object.prototype.hasOwnProperty.call(patch, 'selectedModel') ||
44
66
  Object.prototype.hasOwnProperty.call(patch, 'selectedThinkingLevel')
45
67
  ) {
46
- const snapshot = useChatInputStore.getState().snapshot;
47
- this.reconcileThinkingForModel(snapshot.selectedModel);
68
+ const { selectedModel } = useChatInputStore.getState().snapshot;
69
+ this.reconcileThinkingForModel(selectedModel);
48
70
  }
49
71
  };
50
72
 
@@ -54,7 +76,16 @@ export class ChatInputManager {
54
76
  if (value === prev) {
55
77
  return;
56
78
  }
57
- useChatInputStore.getState().setSnapshot({ draft: value });
79
+ this.syncComposerSnapshot(createChatComposerNodesFromDraft(value));
80
+ };
81
+
82
+ setComposerNodes = (next: SetStateAction<ChatComposerNode[]>) => {
83
+ const prev = useChatInputStore.getState().snapshot.composerNodes;
84
+ const value = this.resolveUpdateValue(prev, next);
85
+ if (Object.is(value, prev)) {
86
+ return;
87
+ }
88
+ this.syncComposerSnapshot(value);
58
89
  };
59
90
 
60
91
  setPendingSessionType = (next: SetStateAction<string>) => {
@@ -73,14 +104,13 @@ export class ChatInputManager {
73
104
  if (!message) {
74
105
  return;
75
106
  }
76
- const requestedSkills = inputSnapshot.selectedSkills;
107
+ const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
77
108
  const hasSelectedSession = Boolean(sessionSnapshot.selectedSessionKey);
78
109
  const sessionKey = sessionSnapshot.selectedSessionKey ?? buildNewSessionKey(sessionSnapshot.selectedAgentId);
79
110
  if (!hasSelectedSession) {
80
111
  this.uiManager.goToSession(sessionKey, { replace: true });
81
112
  }
82
- this.setDraft('');
83
- this.setSelectedSkills([]);
113
+ this.setComposerNodes(createInitialChatComposerNodes());
84
114
  await this.streamActionsManager.sendMessage({
85
115
  message,
86
116
  sessionKey,
@@ -91,7 +121,8 @@ export class ChatInputManager {
91
121
  stopSupported: inputSnapshot.stopSupported,
92
122
  stopReason: inputSnapshot.stopReason,
93
123
  requestedSkills,
94
- restoreDraftOnError: true
124
+ restoreDraftOnError: true,
125
+ composerNodes
95
126
  });
96
127
  };
97
128
 
@@ -129,20 +160,23 @@ export class ChatInputManager {
129
160
  };
130
161
 
131
162
  setSelectedSkills = (next: SetStateAction<string[]>) => {
132
- const prev = useChatInputStore.getState().snapshot.selectedSkills;
163
+ const snapshot = useChatInputStore.getState().snapshot;
164
+ const { selectedSkills: prev } = snapshot;
133
165
  const value = this.resolveUpdateValue(prev, next);
134
- if (Object.is(value, prev)) {
166
+ if (this.isSameStringArray(value, prev)) {
135
167
  return;
136
168
  }
137
- useChatInputStore.getState().setSnapshot({ selectedSkills: value });
169
+ this.syncComposerSnapshot(syncComposerSkills(snapshot.composerNodes, value, snapshot.skillRecords));
138
170
  };
139
171
 
140
172
  selectModel = (value: string) => {
141
173
  this.setSelectedModel(value);
174
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
142
175
  };
143
176
 
144
177
  selectThinkingLevel = (value: ThinkingLevel) => {
145
178
  this.setSelectedThinkingLevel(value);
179
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
146
180
  };
147
181
 
148
182
  selectSkills = (next: string[]) => {
@@ -169,15 +203,16 @@ export class ChatInputManager {
169
203
  private reconcileThinkingForModel(model: string): void {
170
204
  const snapshot = useChatInputStore.getState().snapshot;
171
205
  const modelOption = snapshot.modelOptions.find((option) => option.value === model);
172
- const nextThinking = this.resolveThinkingForModel(modelOption, snapshot.selectedThinkingLevel);
173
- if (nextThinking !== snapshot.selectedThinkingLevel) {
206
+ const { selectedThinkingLevel } = snapshot;
207
+ const nextThinking = this.resolveThinkingForModel(modelOption, selectedThinkingLevel);
208
+ if (nextThinking !== selectedThinkingLevel) {
174
209
  useChatInputStore.getState().setSnapshot({ selectedThinkingLevel: nextThinking });
175
210
  }
176
211
  }
177
212
 
178
213
  private syncRemoteSessionType = async (normalizedType: string) => {
179
214
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
180
- const selectedSessionKey = sessionSnapshot.selectedSessionKey;
215
+ const { selectedSessionKey } = sessionSnapshot;
181
216
  if (!selectedSessionKey) {
182
217
  return;
183
218
  }
@@ -0,0 +1,39 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
3
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
+
6
+ describe('ChatSessionListManager', () => {
7
+ beforeEach(() => {
8
+ useChatInputStore.setState({
9
+ snapshot: {
10
+ ...useChatInputStore.getState().snapshot,
11
+ defaultSessionType: 'native',
12
+ pendingSessionType: 'native'
13
+ }
14
+ });
15
+ useChatSessionListStore.setState({
16
+ snapshot: {
17
+ ...useChatSessionListStore.getState().snapshot,
18
+ selectedSessionKey: 'session-1'
19
+ }
20
+ });
21
+ });
22
+
23
+ it('applies the requested session type when creating a session', () => {
24
+ const uiManager = {
25
+ goToChatRoot: vi.fn()
26
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
27
+ const streamActionsManager = {
28
+ resetStreamState: vi.fn()
29
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
30
+
31
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
32
+ manager.createSession('codex');
33
+
34
+ expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
35
+ expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
36
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
37
+ expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
38
+ });
39
+ });
@@ -53,11 +53,17 @@ export class ChatSessionListManager {
53
53
  useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: value });
54
54
  };
55
55
 
56
- createSession = () => {
57
- const defaultSessionType = useChatInputStore.getState().snapshot.defaultSessionType || 'native';
56
+ createSession = (sessionType?: string) => {
57
+ const { snapshot } = useChatInputStore.getState();
58
+ const { defaultSessionType: configuredDefaultSessionType } = snapshot;
59
+ const defaultSessionType = configuredDefaultSessionType || 'native';
60
+ const nextSessionType =
61
+ typeof sessionType === 'string' && sessionType.trim().length > 0
62
+ ? sessionType.trim()
63
+ : defaultSessionType;
58
64
  this.streamActionsManager.resetStreamState();
59
65
  this.setSelectedSessionKey(null);
60
- useChatInputStore.getState().setSnapshot({ pendingSessionType: defaultSessionType });
66
+ useChatInputStore.getState().setSnapshot({ pendingSessionType: nextSessionType });
61
67
  this.uiManager.goToChatRoot();
62
68
  };
63
69
 
@@ -3,17 +3,18 @@ import { NcpHttpAgentClientEndpoint } from '@nextclaw/ncp-http-agent-client';
3
3
  import { useHydratedNcpAgent, type NcpConversationSeed } from '@nextclaw/ncp-react';
4
4
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
5
5
  import { API_BASE } from '@/api/client';
6
- import { fetchNcpSessionMessages } from '@/api/config';
6
+ import { fetchNcpSessionMessages } from '@/api/ncp-session';
7
7
  import type { ChatRunView } from '@/api/types';
8
8
  import { sessionDisplayName } from '@/components/chat/chat-page-data';
9
9
  import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
10
10
  import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
11
11
  import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
12
12
  import { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
13
- import { adaptNcpMessagesToUiMessages, createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
13
+ import { adaptNcpMessagesToUiMessages, buildNcpSessionRunStatusByKey, createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
14
14
  import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
15
15
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
16
16
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
17
+ import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
17
18
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
18
19
  import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
19
20
 
@@ -70,6 +71,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
70
71
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
71
72
  const threadRef = useRef<HTMLDivElement | null>(null);
72
73
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
74
+ const modelHydratedSessionKeyRef = useRef<string | null>(null);
73
75
  const thinkingHydratedSessionKeyRef = useRef<string | null>(null);
74
76
  const routeSessionKey = useMemo(
75
77
  () => parseSessionKeyFromRoute(routeSessionIdParam),
@@ -84,6 +86,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
84
86
  sessions,
85
87
  skillRecords,
86
88
  selectedSession,
89
+ hydratedSessionModel,
87
90
  selectedSessionThinkingLevel,
88
91
  sessionTypeOptions,
89
92
  defaultSessionType,
@@ -98,6 +101,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
98
101
  setPendingSessionType: presenter.chatInputManager.setPendingSessionType,
99
102
  setSelectedModel: presenter.chatInputManager.setSelectedModel
100
103
  });
104
+ const refetchSessions = sessionsQuery.refetch;
101
105
 
102
106
  const activeSessionId = selectedSessionKey ?? draftSessionId;
103
107
  const sessionSummariesRef = useRef(sessionSummaries);
@@ -161,15 +165,22 @@ export function NcpChatPage({ view }: ChatPageProps) {
161
165
  const stopDisabledReason = agent.isRunning ? null : '__preparing__';
162
166
  const lastSendError = agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
163
167
  const activeBackendRunId = agent.activeRunId;
164
- const sessionRunStatusByKey = useMemo(() => {
165
- const map = new Map<string, 'running'>();
166
- for (const sessionSummary of sessionSummaries) {
167
- if (sessionSummary.status === 'running') {
168
- map.set(sessionSummary.sessionId, 'running');
169
- }
168
+ const sessionRunStatusByKey = useMemo(
169
+ () =>
170
+ buildNcpSessionRunStatusByKey({
171
+ summaries: sessionSummaries,
172
+ activeSessionId,
173
+ isLocallyRunning: isSending || Boolean(activeBackendRunId)
174
+ }),
175
+ [activeBackendRunId, activeSessionId, isSending, sessionSummaries]
176
+ );
177
+
178
+ useEffect(() => {
179
+ if (!isSending && !activeBackendRunId) {
180
+ return;
170
181
  }
171
- return map;
172
- }, [sessionSummaries]);
182
+ void refetchSessions();
183
+ }, [activeBackendRunId, isSending, refetchSessions]);
173
184
 
174
185
  useEffect(() => {
175
186
  presenter.chatStreamActionsManager.bind({
@@ -201,9 +212,17 @@ export function NcpChatPage({ view }: ChatPageProps) {
201
212
  await sessionsQuery.refetch();
202
213
  } catch (error) {
203
214
  if (payload.restoreDraftOnError) {
204
- presenter.chatInputManager.setDraft((currentDraft) =>
205
- currentDraft.trim().length === 0 ? payload.message : currentDraft
206
- );
215
+ if (payload.composerNodes && payload.composerNodes.length > 0) {
216
+ presenter.chatInputManager.setComposerNodes((currentNodes) =>
217
+ currentNodes.length === 1 && currentNodes[0]?.type === 'text' && currentNodes[0].text.length === 0
218
+ ? payload.composerNodes ?? currentNodes
219
+ : currentNodes
220
+ );
221
+ } else {
222
+ presenter.chatInputManager.setDraft((currentDraft) =>
223
+ currentDraft.trim().length === 0 ? payload.message : currentDraft
224
+ );
225
+ }
207
226
  }
208
227
  throw error;
209
228
  }
@@ -248,6 +267,9 @@ export function NcpChatPage({ view }: ChatPageProps) {
248
267
  }, [confirm, location.pathname, navigate, presenter]);
249
268
 
250
269
  const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
270
+ const currentSessionTypeLabel =
271
+ sessionTypeOptions.find((option) => option.value === selectedSessionType)?.label ??
272
+ resolveSessionTypeLabel(selectedSessionType);
251
273
 
252
274
  useEffect(() => {
253
275
  presenter.chatThreadManager.bindActions({
@@ -256,10 +278,19 @@ export function NcpChatPage({ view }: ChatPageProps) {
256
278
  }, [presenter, sessionsQuery.refetch]);
257
279
 
258
280
  useEffect(() => {
281
+ const shouldHydrateModelFromSession =
282
+ !isSending &&
283
+ !isAwaitingAssistantOutput &&
284
+ !sessionsQuery.isLoading &&
285
+ isProviderStateResolved &&
286
+ modelOptions.length > 0 &&
287
+ selectedSessionKey !== modelHydratedSessionKeyRef.current;
259
288
  const shouldHydrateThinkingFromSession =
260
289
  !isSending &&
261
290
  !isAwaitingAssistantOutput &&
262
291
  !agent.isHydrating &&
292
+ isProviderStateResolved &&
293
+ modelOptions.length > 0 &&
263
294
  selectedSessionKey !== thinkingHydratedSessionKeyRef.current;
264
295
 
265
296
  presenter.chatInputManager.syncSnapshot({
@@ -272,6 +303,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
272
303
  sendError: lastSendError,
273
304
  isSending,
274
305
  modelOptions,
306
+ ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
275
307
  sessionTypeOptions,
276
308
  selectedSessionType,
277
309
  ...(shouldHydrateThinkingFromSession ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -280,10 +312,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
280
312
  skillRecords,
281
313
  isSkillsLoading: installedSkillsQuery.isLoading
282
314
  });
315
+ if (shouldHydrateModelFromSession) {
316
+ modelHydratedSessionKeyRef.current = selectedSessionKey;
317
+ }
283
318
  if (shouldHydrateThinkingFromSession) {
284
319
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
285
320
  }
286
321
  if (!selectedSessionKey) {
322
+ modelHydratedSessionKeyRef.current = null;
287
323
  thinkingHydratedSessionKeyRef.current = null;
288
324
  }
289
325
  presenter.chatSessionListManager.syncSnapshot({
@@ -301,6 +337,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
301
337
  modelOptions,
302
338
  sessionTypeUnavailable,
303
339
  sessionTypeUnavailableMessage,
340
+ sessionTypeLabel: currentSessionTypeLabel,
304
341
  selectedSessionKey,
305
342
  sessionDisplayName: currentSessionDisplayName,
306
343
  canDeleteSession: Boolean(selectedSession),
@@ -316,12 +353,15 @@ export function NcpChatPage({ view }: ChatPageProps) {
316
353
  canEditSessionType,
317
354
  canStopCurrentRun,
318
355
  currentSessionDisplayName,
356
+ currentSessionTypeLabel,
319
357
  defaultSessionType,
320
358
  installedSkillsQuery.isLoading,
321
359
  isAwaitingAssistantOutput,
360
+ hydratedSessionModel,
322
361
  isProviderStateResolved,
323
362
  isSending,
324
363
  lastSendError,
364
+ modelOptions.length,
325
365
  modelOptions,
326
366
  presenter,
327
367
  query,