@nextclaw/ui 0.8.0 → 0.9.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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
  3. package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
  4. package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-Dsd8Dlq8.js} +1 -1
  5. package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-2ChEc_oz.js} +1 -1
  6. package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-BXck6-X3.js} +3 -3
  7. package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-CgHRSD0b.js} +1 -1
  8. package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
  9. package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-ClLEKNTN.js} +1 -1
  10. package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-CuXVCbrf.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-udJz6Ake.js} +1 -1
  12. package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-C1XnFfiC.js} +2 -2
  13. package/dist/assets/{chat-message-pxr79GDs.js → chat-message-BETwXLD4.js} +1 -1
  14. package/dist/assets/{index-GdpEEKnz.js → index-COJdlL0e.js} +1 -1
  15. package/dist/assets/index-CsvP4CER.js +8 -0
  16. package/dist/assets/index-D-bXl7qL.css +1 -0
  17. package/dist/assets/{label-CmksBHgc.js → label-BGL-ztxh.js} +1 -1
  18. package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-aw88k7tG.js} +1 -1
  19. package/dist/assets/popover-DyEvzhmV.js +1 -0
  20. package/dist/assets/{security-config-CjLFME5Q.js → security-config-BuPAQn82.js} +1 -1
  21. package/dist/assets/skeleton-drzO_tdU.js +1 -0
  22. package/dist/assets/{switch-C24d-UJU.js → switch-BK8jIzto.js} +1 -1
  23. package/dist/assets/tabs-custom-Da3cEOji.js +1 -0
  24. package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-z0CE92iS.js} +1 -1
  25. package/dist/assets/{vendor-psXJBy9u.js → vendor-CkJHmX1g.js} +1 -1
  26. package/dist/index.html +3 -3
  27. package/package.json +2 -2
  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-page-data.ts +30 -1
  38. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  39. package/src/components/chat/chat-page-runtime.ts +101 -15
  40. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  41. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  42. package/src/components/chat/containers/chat-input-bar.container.tsx +0 -22
  43. package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
  44. package/src/components/chat/legacy/LegacyChatPage.tsx +24 -0
  45. package/src/components/chat/managers/chat-input.manager.ts +5 -0
  46. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  47. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  48. package/src/components/chat/ncp/NcpChatPage.tsx +42 -10
  49. package/src/components/chat/ncp/ncp-chat-input.manager.ts +6 -0
  50. package/src/components/chat/ncp/ncp-chat-page-data.ts +34 -2
  51. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +1 -1
  52. package/src/components/chat/ncp/ncp-session-adapter.test.ts +27 -1
  53. package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
  54. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  55. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  56. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  57. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  58. package/src/hooks/useConfig.ts +2 -4
  59. package/src/hooks/useMarketplace.ts +7 -4
  60. package/src/hooks/useWebSocket.ts +23 -2
  61. package/dist/assets/ChannelsList-DBcoVJRW.js +0 -1
  62. package/dist/assets/ChatPage-CD3cxyyM.js +0 -37
  63. package/dist/assets/ProvidersList-kwzRS8_M.js +0 -1
  64. package/dist/assets/index-BIvFMkN4.js +0 -1
  65. package/dist/assets/index-CzkY1reu.js +0 -8
  66. package/dist/assets/index-RZ0kHHRI.css +0 -1
  67. package/dist/assets/skeleton-CkpQeVWN.js +0 -1
  68. package/dist/assets/tabs-custom-D89bh-fc.js +0 -1
@@ -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,
@@ -125,6 +128,9 @@ export function LegacyChatPage({ view }: ChatPageProps) {
125
128
  }, [confirm, location.pathname, navigate, presenter]);
126
129
 
127
130
  const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
131
+ const currentSessionTypeLabel =
132
+ sessionTypeOptions.find((option) => option.value === selectedSessionType)?.label ??
133
+ resolveSessionTypeLabel(selectedSessionType);
128
134
 
129
135
  useEffect(() => {
130
136
  presenter.chatThreadManager.bindActions({
@@ -133,10 +139,19 @@ export function LegacyChatPage({ view }: ChatPageProps) {
133
139
  }, [presenter, sessionsQuery.refetch]);
134
140
 
135
141
  useEffect(() => {
142
+ const shouldHydrateModelFromSession =
143
+ !isSending &&
144
+ !isAwaitingAssistantOutput &&
145
+ !sessionsQuery.isLoading &&
146
+ isProviderStateResolved &&
147
+ modelOptions.length > 0 &&
148
+ selectedSessionKey !== modelHydratedSessionKeyRef.current;
136
149
  const shouldHydrateThinkingFromHistory =
137
150
  !isSending &&
138
151
  !isAwaitingAssistantOutput &&
139
152
  !historyQuery.isLoading &&
153
+ isProviderStateResolved &&
154
+ modelOptions.length > 0 &&
140
155
  selectedSessionKey !== thinkingHydratedSessionKeyRef.current;
141
156
 
142
157
  presenter.chatInputManager.syncSnapshot({
@@ -149,6 +164,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
149
164
  sendError: lastSendError,
150
165
  isSending,
151
166
  modelOptions,
167
+ ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
152
168
  sessionTypeOptions,
153
169
  selectedSessionType,
154
170
  ...(shouldHydrateThinkingFromHistory ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -157,10 +173,14 @@ export function LegacyChatPage({ view }: ChatPageProps) {
157
173
  skillRecords,
158
174
  isSkillsLoading: installedSkillsQuery.isLoading
159
175
  });
176
+ if (shouldHydrateModelFromSession) {
177
+ modelHydratedSessionKeyRef.current = selectedSessionKey;
178
+ }
160
179
  if (shouldHydrateThinkingFromHistory) {
161
180
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
162
181
  }
163
182
  if (!selectedSessionKey) {
183
+ modelHydratedSessionKeyRef.current = null;
164
184
  thinkingHydratedSessionKeyRef.current = null;
165
185
  }
166
186
  presenter.chatSessionListManager.syncSnapshot({
@@ -178,6 +198,7 @@ export function LegacyChatPage({ view }: ChatPageProps) {
178
198
  modelOptions,
179
199
  sessionTypeUnavailable,
180
200
  sessionTypeUnavailableMessage,
201
+ sessionTypeLabel: currentSessionTypeLabel,
181
202
  selectedSessionKey,
182
203
  sessionDisplayName: currentSessionDisplayName,
183
204
  canDeleteSession: Boolean(selectedSession),
@@ -192,15 +213,18 @@ export function LegacyChatPage({ view }: ChatPageProps) {
192
213
  canEditSessionType,
193
214
  canStopCurrentRun,
194
215
  currentSessionDisplayName,
216
+ currentSessionTypeLabel,
195
217
  chatCapabilitiesQuery.data?.stopReason,
196
218
  chatCapabilitiesQuery.data?.stopSupported,
197
219
  defaultSessionType,
198
220
  historyQuery.isLoading,
199
221
  installedSkillsQuery.isLoading,
200
222
  isAwaitingAssistantOutput,
223
+ hydratedSessionModel,
201
224
  isProviderStateResolved,
202
225
  isSending,
203
226
  lastSendError,
227
+ modelOptions.length,
204
228
  modelOptions,
205
229
  presenter,
206
230
  query,
@@ -5,12 +5,15 @@ import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
5
5
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
6
6
  import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
7
7
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
8
+ import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
8
9
  import type { SetStateAction } from 'react';
9
10
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
10
11
  import type { ThinkingLevel } from '@/api/types';
11
12
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
12
13
 
13
14
  export class ChatInputManager {
15
+ private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateSession);
16
+
14
17
  constructor(
15
18
  private uiManager: ChatUiManager,
16
19
  private streamActionsManager: ChatStreamActionsManager
@@ -139,10 +142,12 @@ export class ChatInputManager {
139
142
 
140
143
  selectModel = (value: string) => {
141
144
  this.setSelectedModel(value);
145
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
142
146
  };
143
147
 
144
148
  selectThinkingLevel = (value: ThinkingLevel) => {
145
149
  this.setSelectedThinkingLevel(value);
150
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
146
151
  };
147
152
 
148
153
  selectSkills = (next: string[]) => {
@@ -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({
@@ -248,6 +259,9 @@ export function NcpChatPage({ view }: ChatPageProps) {
248
259
  }, [confirm, location.pathname, navigate, presenter]);
249
260
 
250
261
  const currentSessionDisplayName = selectedSession ? sessionDisplayName(selectedSession) : undefined;
262
+ const currentSessionTypeLabel =
263
+ sessionTypeOptions.find((option) => option.value === selectedSessionType)?.label ??
264
+ resolveSessionTypeLabel(selectedSessionType);
251
265
 
252
266
  useEffect(() => {
253
267
  presenter.chatThreadManager.bindActions({
@@ -256,10 +270,19 @@ export function NcpChatPage({ view }: ChatPageProps) {
256
270
  }, [presenter, sessionsQuery.refetch]);
257
271
 
258
272
  useEffect(() => {
273
+ const shouldHydrateModelFromSession =
274
+ !isSending &&
275
+ !isAwaitingAssistantOutput &&
276
+ !sessionsQuery.isLoading &&
277
+ isProviderStateResolved &&
278
+ modelOptions.length > 0 &&
279
+ selectedSessionKey !== modelHydratedSessionKeyRef.current;
259
280
  const shouldHydrateThinkingFromSession =
260
281
  !isSending &&
261
282
  !isAwaitingAssistantOutput &&
262
283
  !agent.isHydrating &&
284
+ isProviderStateResolved &&
285
+ modelOptions.length > 0 &&
263
286
  selectedSessionKey !== thinkingHydratedSessionKeyRef.current;
264
287
 
265
288
  presenter.chatInputManager.syncSnapshot({
@@ -272,6 +295,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
272
295
  sendError: lastSendError,
273
296
  isSending,
274
297
  modelOptions,
298
+ ...(shouldHydrateModelFromSession ? { selectedModel: hydratedSessionModel } : {}),
275
299
  sessionTypeOptions,
276
300
  selectedSessionType,
277
301
  ...(shouldHydrateThinkingFromSession ? { selectedThinkingLevel: selectedSessionThinkingLevel } : {}),
@@ -280,10 +304,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
280
304
  skillRecords,
281
305
  isSkillsLoading: installedSkillsQuery.isLoading
282
306
  });
307
+ if (shouldHydrateModelFromSession) {
308
+ modelHydratedSessionKeyRef.current = selectedSessionKey;
309
+ }
283
310
  if (shouldHydrateThinkingFromSession) {
284
311
  thinkingHydratedSessionKeyRef.current = selectedSessionKey;
285
312
  }
286
313
  if (!selectedSessionKey) {
314
+ modelHydratedSessionKeyRef.current = null;
287
315
  thinkingHydratedSessionKeyRef.current = null;
288
316
  }
289
317
  presenter.chatSessionListManager.syncSnapshot({
@@ -301,6 +329,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
301
329
  modelOptions,
302
330
  sessionTypeUnavailable,
303
331
  sessionTypeUnavailableMessage,
332
+ sessionTypeLabel: currentSessionTypeLabel,
304
333
  selectedSessionKey,
305
334
  sessionDisplayName: currentSessionDisplayName,
306
335
  canDeleteSession: Boolean(selectedSession),
@@ -316,12 +345,15 @@ export function NcpChatPage({ view }: ChatPageProps) {
316
345
  canEditSessionType,
317
346
  canStopCurrentRun,
318
347
  currentSessionDisplayName,
348
+ currentSessionTypeLabel,
319
349
  defaultSessionType,
320
350
  installedSkillsQuery.isLoading,
321
351
  isAwaitingAssistantOutput,
352
+ hydratedSessionModel,
322
353
  isProviderStateResolved,
323
354
  isSending,
324
355
  lastSendError,
356
+ modelOptions.length,
325
357
  modelOptions,
326
358
  presenter,
327
359
  query,
@@ -1,14 +1,18 @@
1
1
  import type { SetStateAction } from 'react';
2
2
  import type { ThinkingLevel } from '@/api/types';
3
+ import { updateNcpSession } from '@/api/ncp-session';
3
4
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
5
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
6
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
6
7
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
7
8
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
9
+ import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
8
10
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
9
11
  import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
10
12
 
11
13
  export class NcpChatInputManager {
14
+ private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateNcpSession);
15
+
12
16
  constructor(
13
17
  private uiManager: ChatUiManager,
14
18
  private streamActionsManager: ChatStreamActionsManager,
@@ -135,10 +139,12 @@ export class NcpChatInputManager {
135
139
 
136
140
  selectModel = (value: string) => {
137
141
  this.setSelectedModel(value);
142
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
138
143
  };
139
144
 
140
145
  selectThinkingLevel = (value: ThinkingLevel) => {
141
146
  this.setSelectedThinkingLevel(value);
147
+ this.sessionPreferenceSync.syncSelectedSessionPreferences();
142
148
  };
143
149
 
144
150
  selectSkills = (next: string[]) => {
@@ -7,12 +7,17 @@ import {
7
7
  readNcpSessionPreferredThinking
8
8
  } from '@/components/chat/ncp/ncp-session-adapter';
9
9
  import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
10
- import { useSyncSelectedModel } from '@/components/chat/chat-page-runtime';
10
+ import {
11
+ resolveSelectedModelValue,
12
+ resolveRecentSessionPreferredModel,
13
+ useSyncSelectedModel
14
+ } from '@/components/chat/chat-page-runtime';
11
15
  import {
12
16
  useConfig,
13
17
  useConfigMeta,
14
18
  useNcpSessions
15
19
  } from '@/hooks/useConfig';
20
+ import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
16
21
  import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
17
22
  import { buildProviderModelCatalog, composeProviderModel, resolveModelThinkingCapability } from '@/lib/provider-models';
18
23
 
@@ -36,6 +41,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
36
41
  const configQuery = useConfig();
37
42
  const configMetaQuery = useConfigMeta();
38
43
  const sessionsQuery = useNcpSessions({ limit: 200 });
44
+ const sessionTypesQuery = useNcpChatSessionTypes();
39
45
  const installedSkillsQuery = useMarketplaceInstalled('skill');
40
46
  const isProviderStateResolved =
41
47
  (configQuery.isFetched || configQuery.isSuccess) &&
@@ -107,20 +113,45 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
107
113
  selectedSessionKey: params.selectedSessionKey,
108
114
  pendingSessionType: params.pendingSessionType,
109
115
  setPendingSessionType: params.setPendingSessionType,
110
- sessionTypesData: null
116
+ sessionTypesData: sessionTypesQuery.data
111
117
  });
118
+ const recentSessionPreferredModel = useMemo(
119
+ () =>
120
+ resolveRecentSessionPreferredModel({
121
+ sessions: allSessions,
122
+ selectedSessionKey: params.selectedSessionKey,
123
+ sessionType: sessionTypeState.selectedSessionType
124
+ }),
125
+ [allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
126
+ );
112
127
 
113
128
  useSyncSelectedModel({
114
129
  modelOptions,
130
+ selectedSessionKey: params.selectedSessionKey,
115
131
  selectedSessionPreferredModel: selectedSession?.preferredModel,
132
+ fallbackPreferredModel: recentSessionPreferredModel,
116
133
  defaultModel: configQuery.data?.agents.defaults.model,
117
134
  setSelectedModel: params.setSelectedModel
118
135
  });
119
136
 
137
+ const hydratedSessionModel = useMemo(
138
+ () =>
139
+ resolveSelectedModelValue({
140
+ currentSelectedModel: '',
141
+ modelOptions,
142
+ selectedSessionPreferredModel: selectedSession?.preferredModel,
143
+ fallbackPreferredModel: recentSessionPreferredModel,
144
+ defaultModel: configQuery.data?.agents.defaults.model,
145
+ preferSessionPreferredModel: true
146
+ }),
147
+ [configQuery.data?.agents.defaults.model, modelOptions, recentSessionPreferredModel, selectedSession?.preferredModel]
148
+ );
149
+
120
150
  return {
121
151
  configQuery,
122
152
  configMetaQuery,
123
153
  sessionsQuery,
154
+ sessionTypesQuery,
124
155
  installedSkillsQuery,
125
156
  isProviderStateResolved,
126
157
  modelOptions,
@@ -128,6 +159,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
128
159
  sessions,
129
160
  skillRecords,
130
161
  selectedSession,
162
+ hydratedSessionModel,
131
163
  selectedSessionThinkingLevel,
132
164
  ...sessionTypeState
133
165
  };
@@ -1,4 +1,4 @@
1
- import { deleteNcpSession as deleteNcpSessionApi } from '@/api/config';
1
+ import { deleteNcpSession as deleteNcpSessionApi } from '@/api/ncp-session';
2
2
  import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
3
3
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
4
4
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
@@ -1,4 +1,8 @@
1
- import { adaptNcpSessionSummary, readNcpSessionPreferredThinking } from '@/components/chat/ncp/ncp-session-adapter';
1
+ import {
2
+ adaptNcpSessionSummary,
3
+ buildNcpSessionRunStatusByKey,
4
+ readNcpSessionPreferredThinking
5
+ } from '@/components/chat/ncp/ncp-session-adapter';
2
6
  import type { NcpSessionSummaryView } from '@/api/types';
3
7
 
4
8
  function createSummary(partial: Partial<NcpSessionSummaryView> = {}): NcpSessionSummaryView {
@@ -47,3 +51,25 @@ describe('readNcpSessionPreferredThinking', () => {
47
51
  expect(thinking).toBe('high');
48
52
  });
49
53
  });
54
+
55
+ describe('buildNcpSessionRunStatusByKey', () => {
56
+ it('marks the active local session as running before the server summary catches up', () => {
57
+ const statuses = buildNcpSessionRunStatusByKey({
58
+ summaries: [createSummary({ sessionId: 'ncp-session-1', status: 'idle' })],
59
+ activeSessionId: 'ncp-session-1',
60
+ isLocallyRunning: true
61
+ });
62
+
63
+ expect(statuses.get('ncp-session-1')).toBe('running');
64
+ });
65
+
66
+ it('keeps persisted running sessions marked as running', () => {
67
+ const statuses = buildNcpSessionRunStatusByKey({
68
+ summaries: [createSummary({ sessionId: 'ncp-session-2', status: 'running' })],
69
+ activeSessionId: null,
70
+ isLocallyRunning: false
71
+ });
72
+
73
+ expect(statuses.get('ncp-session-2')).toBe('running');
74
+ });
75
+ });
@@ -1,6 +1,7 @@
1
1
  import { ToolInvocationStatus, type UIMessage } from '@nextclaw/agent-chat';
2
2
  import type { NcpMessagePart } from '@nextclaw/ncp';
3
3
  import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView, ThinkingLevel } from '@/api/types';
4
+ import type { SessionRunStatus } from '@/lib/session-run-status';
4
5
 
5
6
  const THINKING_LEVEL_SET = new Set<string>(['off', 'minimal', 'low', 'medium', 'high', 'adaptive', 'xhigh']);
6
7
 
@@ -167,6 +168,7 @@ export function adaptNcpMessageToUiMessage(message: NcpMessageView): UIMessage {
167
168
  }
168
169
 
169
170
  export function adaptNcpMessagesToUiMessages(messages: readonly NcpMessageView[]): UIMessage[] {
171
+ console.log('[adaptNcpMessagesToUiMessages]', { messages });
170
172
  return messages.map(adaptNcpMessageToUiMessage);
171
173
  }
172
174
 
@@ -189,6 +191,24 @@ export function adaptNcpSessionSummaries(summaries: NcpSessionSummaryView[]): Se
189
191
  return summaries.map(adaptNcpSessionSummary);
190
192
  }
191
193
 
194
+ export function buildNcpSessionRunStatusByKey(params: {
195
+ summaries: readonly NcpSessionSummaryView[];
196
+ activeSessionId?: string | null;
197
+ isLocallyRunning?: boolean;
198
+ }): Map<string, SessionRunStatus> {
199
+ const map = new Map<string, SessionRunStatus>();
200
+ for (const summary of params.summaries) {
201
+ if (summary.status === 'running') {
202
+ map.set(summary.sessionId, 'running');
203
+ }
204
+ }
205
+ const activeSessionId = readOptionalString(params.activeSessionId);
206
+ if (params.isLocallyRunning && activeSessionId) {
207
+ map.set(activeSessionId, 'running');
208
+ }
209
+ return map;
210
+ }
211
+
192
212
  export function createNcpSessionId(): string {
193
213
  return `ncp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
194
214
  }
@@ -8,6 +8,7 @@ export type ChatThreadSnapshot = {
8
8
  modelOptions: ChatModelOption[];
9
9
  sessionTypeUnavailable: boolean;
10
10
  sessionTypeUnavailableMessage?: string | null;
11
+ sessionTypeLabel?: string | null;
11
12
  selectedSessionKey: string | null;
12
13
  sessionDisplayName?: string;
13
14
  canDeleteSession: boolean;
@@ -29,6 +30,7 @@ const initialSnapshot: ChatThreadSnapshot = {
29
30
  modelOptions: [],
30
31
  sessionTypeUnavailable: false,
31
32
  sessionTypeUnavailableMessage: null,
33
+ sessionTypeLabel: null,
32
34
  selectedSessionKey: null,
33
35
  sessionDisplayName: undefined,
34
36
  canDeleteSession: false,
@@ -0,0 +1,58 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { resolveSessionTypeLabel, useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
4
+
5
+ vi.mock('@/lib/i18n', () => ({
6
+ t: (key: string) => key
7
+ }));
8
+
9
+ describe('useChatSessionTypeState', () => {
10
+ it('formats non-native runtime labels generically when no explicit label is provided', () => {
11
+ expect(resolveSessionTypeLabel('workspace-agent')).toBe('Workspace Agent');
12
+ });
13
+
14
+ it('preserves an explicitly selected draft session type instead of resetting to the default', () => {
15
+ const setPendingSessionType = vi.fn();
16
+
17
+ const { result } = renderHook(() =>
18
+ useChatSessionTypeState({
19
+ selectedSession: null,
20
+ selectedSessionKey: null,
21
+ pendingSessionType: 'codex-sdk',
22
+ setPendingSessionType,
23
+ sessionTypesData: {
24
+ defaultType: 'native',
25
+ options: [
26
+ { value: 'native', label: 'Native' },
27
+ { value: 'codex-sdk', label: 'Codex' }
28
+ ]
29
+ }
30
+ })
31
+ );
32
+
33
+ expect(result.current.selectedSessionType).toBe('codex-sdk');
34
+ expect(setPendingSessionType).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('hydrates the draft session type from the runtime default when no explicit type exists', () => {
38
+ const setPendingSessionType = vi.fn();
39
+
40
+ renderHook(() =>
41
+ useChatSessionTypeState({
42
+ selectedSession: null,
43
+ selectedSessionKey: null,
44
+ pendingSessionType: '',
45
+ setPendingSessionType,
46
+ sessionTypesData: {
47
+ defaultType: 'codex-sdk',
48
+ options: [
49
+ { value: 'native', label: 'Native' },
50
+ { value: 'codex-sdk', label: 'Codex' }
51
+ ]
52
+ }
53
+ })
54
+ );
55
+
56
+ expect(setPendingSessionType).toHaveBeenCalledWith('codex-sdk');
57
+ });
58
+ });