@nextclaw/ui 0.18.0 → 0.18.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 (25) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/assets/{appearance-settings-page-DAF1fd14.js → appearance-settings-page-CR2LJW8I.js} +1 -1
  3. package/dist/assets/{channels-list-page-Dubt8r9S.js → channels-list-page-BXyx_gxw.js} +1 -1
  4. package/dist/assets/{chat-page-JbwjeUo4.js → chat-page-OP5V5mAm.js} +1 -1
  5. package/dist/assets/{desktop-update-config-DFefhK96.js → desktop-update-config-eb7REelt.js} +1 -1
  6. package/dist/assets/{index-L58BO782.js → index-DotCWGD1.js} +51 -51
  7. package/dist/assets/{model-config-page-jYCwpq4U.js → model-config-page-BOhaenKN.js} +1 -1
  8. package/dist/assets/{provider-scoped-model-input-C1CUHVJx.js → provider-scoped-model-input-DlohNUbq.js} +1 -1
  9. package/dist/assets/{providers-config-page-CMvI4R1e.js → providers-config-page-eYaDPENc.js} +1 -1
  10. package/dist/assets/remote-Cx_8UTi5.js +1 -0
  11. package/dist/assets/{runtime-config-page-DYuYv5yM.js → runtime-config-page-D5lV05VE.js} +1 -1
  12. package/dist/assets/{search-config-page-GUtGbk6i.js → search-config-page-DAWe6n7E.js} +1 -1
  13. package/dist/assets/{secrets-config-page-Delo8ab0.js → secrets-config-page-DyFeZDAi.js} +1 -1
  14. package/dist/index.html +1 -1
  15. package/package.json +2 -2
  16. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +100 -1
  17. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +17 -17
  18. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +31 -0
  19. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-preference-persistence.test.tsx +110 -0
  20. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +3 -2
  21. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +30 -3
  22. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence.ts +88 -0
  23. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-update.test.tsx +27 -0
  24. package/src/features/chat/features/session/hooks/use-chat-session-update.ts +17 -6
  25. package/dist/assets/remote-BYPq49kD.js +0 -1
@@ -29,9 +29,12 @@ import type {
29
29
  } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
30
30
  import { useSessionConversationInputState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
31
31
  import { useChatMessageLayoutStore } from '@/features/chat/stores/chat-message-layout.store';
32
+ import { useChatComposerDraftStore } from '@/features/chat/stores/chat-composer-draft.store';
32
33
  import { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
34
+ import type { ThinkingLevel } from '@/shared/lib/api';
33
35
 
34
36
  const uploadNcpAssetsMock = vi.hoisted(() => vi.fn());
37
+ const updateNcpSessionMock = vi.hoisted(() => vi.fn());
35
38
 
36
39
  function renderInput(input: ReactElement) {
37
40
  const queryClient = new QueryClient({
@@ -59,6 +62,7 @@ Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
59
62
 
60
63
  afterEach(() => {
61
64
  useChatMessageLayoutStore.getState().setLayout('card');
65
+ updateNcpSessionMock.mockReset();
62
66
  });
63
67
 
64
68
  vi.mock('@/app/hooks/use-viewport-layout', () => ({
@@ -111,11 +115,106 @@ vi.mock('@/shared/lib/api', async (importOriginal) => {
111
115
  const actual = await importOriginal();
112
116
  return {
113
117
  ...(actual as object),
114
- updateNcpSession: vi.fn(),
118
+ updateNcpSession: updateNcpSessionMock,
115
119
  uploadNcpAssets: uploadNcpAssetsMock,
116
120
  };
117
121
  });
118
122
 
123
+ function ThinkingPreferenceHarness() {
124
+ const { inputActions, inputSnapshot } = useSessionConversationInputState(
125
+ undefined,
126
+ 'thinking-session',
127
+ );
128
+ const inputQuery = useMemo(() => ({
129
+ addDiscoveredModel: vi.fn(async () => null),
130
+ dismissDiscoveredModels: vi.fn(),
131
+ defaultModel: 'openai/gpt-5.6',
132
+ defaultProjectRoot: null,
133
+ discoveredModelOptions: [],
134
+ fallbackPreferredModel: undefined,
135
+ fallbackPreferredThinking: 'high' as const,
136
+ isProviderStateResolved: true,
137
+ isSkillsLoading: false,
138
+ modelOptions: [{
139
+ value: 'openai/gpt-5.6',
140
+ modelLabel: 'GPT-5.6',
141
+ providerLabel: 'OpenAI',
142
+ thinkingCapability: {
143
+ supported: ['off', 'low', 'medium', 'high'] as ThinkingLevel[],
144
+ default: 'high' as const,
145
+ },
146
+ }],
147
+ providersView: null,
148
+ refreshProviderModelCatalog: vi.fn(),
149
+ selectedSession: null,
150
+ selectedSessionKey: 'thinking-session',
151
+ sessionTypeState: {
152
+ canEditSessionType: true,
153
+ defaultSessionType: 'native',
154
+ selectedSessionType: 'native',
155
+ selectedSessionTypeOption: null,
156
+ sessionTypeOptions: [],
157
+ sessionTypeUnavailable: false,
158
+ sessionTypeUnavailableMessage: null,
159
+ },
160
+ skillRecords: [],
161
+ }), []);
162
+
163
+ return (
164
+ <I18nProvider>
165
+ <ChatPresenterProvider presenter={presenter}>
166
+ <SessionConversationInput
167
+ contextWindow={null}
168
+ controller={{ ...controller, isSending: false, sendDisabled: false }}
169
+ inputActions={inputActions}
170
+ inputQuery={inputQuery}
171
+ inputSnapshot={inputSnapshot}
172
+ />
173
+ </ChatPresenterProvider>
174
+ </I18nProvider>
175
+ );
176
+ }
177
+
178
+ describe('SessionConversationInput thinking preference', () => {
179
+ it('switches from high to explicit off and persists the same value', async () => {
180
+ useChatComposerDraftStore.setState({
181
+ drafts: {
182
+ 'session:thinking-session': {
183
+ attachments: [],
184
+ composerFocusRequestId: 0,
185
+ nodes: [],
186
+ pendingSessionType: 'native',
187
+ selectedModel: 'openai/gpt-5.6',
188
+ selectedSessionType: 'native',
189
+ selectedSkills: [],
190
+ selectedThinkingLevel: 'high',
191
+ sendError: null,
192
+ skillRecords: [],
193
+ text: '',
194
+ },
195
+ },
196
+ });
197
+ updateNcpSessionMock.mockResolvedValue({
198
+ sessionId: 'thinking-session',
199
+ updatedAt: '2026-08-15T00:00:00.000Z',
200
+ status: 'idle',
201
+ metadata: { preferred_thinking: 'off' },
202
+ });
203
+ renderInput(<ThinkingPreferenceHarness />);
204
+
205
+ fireEvent.click(screen.getByRole('combobox', { name: /High/ }));
206
+ fireEvent.click(await screen.findByRole('option', { name: /Off|关闭/ }));
207
+
208
+ await waitFor(() => expect(
209
+ screen.getByRole('combobox', { name: /Off|关闭/ }),
210
+ ).toBeTruthy());
211
+ await waitFor(() => expect(updateNcpSessionMock).toHaveBeenCalledWith(
212
+ 'thinking-session',
213
+ { preferredThinking: 'off' },
214
+ ));
215
+ });
216
+ });
217
+
119
218
  type StreamingInputControl = {
120
219
  bumpStream: () => void;
121
220
  };
@@ -14,7 +14,7 @@ import { isRuntimeDefaultModelValue } from '@nextclaw/shared';
14
14
 
15
15
  import { useI18n } from '@/app/components/i18n-provider';
16
16
  import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
17
- import { updateNcpSession, type SessionSkillEntryView, type ThinkingLevel } from '@/shared/lib/api';
17
+ import { type SessionSkillEntryView } from '@/shared/lib/api';
18
18
  import { t } from '@/shared/lib/i18n';
19
19
  import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
20
20
  import {
@@ -46,6 +46,7 @@ import {
46
46
  import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
47
47
  import { useChatComposerReferenceIntent } from '@/features/chat/features/conversation/hooks/use-chat-composer-reference-intent';
48
48
  import { useSessionConversationComposerNodes } from '@/features/chat/features/conversation/hooks/use-session-conversation-composer-nodes';
49
+ import { useSessionConversationPreferencePersistence } from '@/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence';
49
50
  import { useSessionConversationSlashCommands } from '@/features/chat/features/conversation/hooks/use-session-conversation-slash-commands';
50
51
  import { useSystemObjectReferenceSelect } from '@/features/chat/features/conversation/hooks/use-system-object-reference-select';
51
52
  import { ChatConversationTrack } from '@/features/chat/components/conversation/chat-conversation-track';
@@ -281,15 +282,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
281
282
  inputBarRef,
282
283
  addAttachments: inputActions.addAttachments,
283
284
  });
284
- const syncSessionPreferences = useCallback((patch: {
285
- preferredModel?: string | null;
286
- preferredThinking?: ThinkingLevel | null;
287
- }) => {
288
- if (!inputQuery.selectedSessionKey || !inputQuery.selectedSession) {
289
- return;
290
- }
291
- void updateNcpSession(inputQuery.selectedSessionKey, patch).catch(() => undefined);
292
- }, [inputQuery.selectedSession, inputQuery.selectedSessionKey]);
285
+ const persistSessionPreferences = useSessionConversationPreferencePersistence({ inputActions, selectedSessionKey: inputQuery.selectedSessionKey });
293
286
  const handleNodesChange = useSessionConversationComposerNodes(inputActions);
294
287
 
295
288
  useChatComposerReferenceIntent({
@@ -315,17 +308,22 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
315
308
  if (!isRuntimeDefaultModelValue(value)) {
316
309
  chatRecentModelsManager.remember(value);
317
310
  }
318
- syncSessionPreferences({
319
- preferredModel: isRuntimeDefaultModelValue(value) ? null : value,
320
- preferredThinking: nextThinkingLevel,
321
- });
311
+ persistSessionPreferences(
312
+ {
313
+ preferredModel: isRuntimeDefaultModelValue(value) ? null : value,
314
+ preferredThinking: nextThinkingLevel,
315
+ },
316
+ { selectedModel: value, selectedThinkingLevel: nextThinkingLevel },
317
+ { selectedModel, selectedThinkingLevel },
318
+ );
322
319
  }, [
323
320
  inputActions,
324
321
  discoveredModelRecords,
325
322
  inputQuery.sessionTypeState.selectedSessionType,
326
323
  modelRecords,
327
324
  selectedThinkingLevel,
328
- syncSessionPreferences,
325
+ persistSessionPreferences,
326
+ selectedModel,
329
327
  ]);
330
328
  const handleDiscoveredModelSelect = useCallback(async (value: string) => {
331
329
  const option = await inputQuery.addDiscoveredModel(value);
@@ -339,8 +337,10 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
339
337
  ]);
340
338
  const handleThinkingChange = useCallback((value: ChatThinkingLevel | null) => {
341
339
  inputActions.setSelectedThinkingLevel(value);
342
- syncSessionPreferences({ preferredThinking: value });
343
- }, [inputActions, syncSessionPreferences]);
340
+ persistSessionPreferences(
341
+ { preferredThinking: value }, { selectedThinkingLevel: value }, { selectedThinkingLevel },
342
+ );
343
+ }, [inputActions, persistSessionPreferences, selectedThinkingLevel]);
344
344
  const handleSelectedSkillsChange = useCallback((next: string[]) => {
345
345
  const previousSelection = inputSnapshot.selectedSkills;
346
346
  next
@@ -21,6 +21,18 @@ const MODEL_OPTIONS: ChatModelOption[] = [
21
21
  },
22
22
  ];
23
23
 
24
+ const THINKING_MODEL_OPTIONS: ChatModelOption[] = [
25
+ {
26
+ value: 'openai/gpt-5.6',
27
+ modelLabel: 'GPT-5.6',
28
+ providerLabel: 'OpenAI',
29
+ thinkingCapability: {
30
+ supported: ['off', 'low', 'medium', 'high'],
31
+ default: 'high',
32
+ },
33
+ },
34
+ ];
35
+
24
36
  describe('useSessionConversationInputState session preferences', () => {
25
37
  beforeEach(() => {
26
38
  window.localStorage.clear();
@@ -36,6 +48,25 @@ describe('useSessionConversationInputState session preferences', () => {
36
48
  expect(result.current.inputSnapshot.composerFocusRequestId).toBe(1);
37
49
  });
38
50
 
51
+ it('does not let first preference hydration overwrite an explicit off selection', () => {
52
+ const { result } = renderHook(() => useSessionConversationInputState());
53
+
54
+ act(() => {
55
+ result.current.inputActions.setSelectedThinkingLevel('off');
56
+ });
57
+ act(() => {
58
+ result.current.inputActions.syncSessionPreferences({
59
+ fallbackPreferredThinking: 'high',
60
+ modelOptions: THINKING_MODEL_OPTIONS,
61
+ selectedSessionExists: false,
62
+ selectedSessionKey: null,
63
+ selectedSessionType: 'native',
64
+ });
65
+ });
66
+
67
+ expect(result.current.inputSnapshot.selectedThinkingLevel).toBe('off');
68
+ });
69
+
39
70
  it('restores the switched session model after its metadata becomes available', () => {
40
71
  const { result } = renderHook(() => useSessionConversationInputState());
41
72
 
@@ -0,0 +1,110 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useSessionConversationPreferencePersistence } from '@/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence';
5
+ import type { SessionConversationInputActions } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ updateSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/features/chat/features/session/hooks/use-chat-session-update', () => ({
12
+ useChatSessionUpdate: () => mocks.updateSession,
13
+ }));
14
+
15
+ function createInputActions() {
16
+ return {
17
+ update: vi.fn(),
18
+ } as unknown as SessionConversationInputActions;
19
+ }
20
+
21
+ describe('useSessionConversationPreferencePersistence', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ mocks.updateSession.mockResolvedValue(undefined);
25
+ });
26
+
27
+ it('persists explicit off for a selected session without a success toast', async () => {
28
+ const inputActions = createInputActions();
29
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
30
+ inputActions,
31
+ selectedSessionKey: 'session-1',
32
+ }));
33
+
34
+ act(() => result.current(
35
+ { preferredThinking: 'off' },
36
+ { selectedThinkingLevel: 'off' },
37
+ { selectedThinkingLevel: 'high' },
38
+ ));
39
+
40
+ await waitFor(() => expect(mocks.updateSession).toHaveBeenCalledWith({
41
+ invalidateSessionSkills: false,
42
+ sessionKey: 'session-1',
43
+ patch: { preferredThinking: 'off' },
44
+ successMessage: null,
45
+ }));
46
+ });
47
+
48
+ it('rolls back the latest failed thinking mutation only when it is still visible', async () => {
49
+ mocks.updateSession.mockRejectedValueOnce(new Error('save failed'));
50
+ const inputActions = createInputActions();
51
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
52
+ inputActions,
53
+ selectedSessionKey: 'session-1',
54
+ }));
55
+
56
+ act(() => result.current(
57
+ { preferredThinking: 'off' },
58
+ { selectedThinkingLevel: 'off' },
59
+ { selectedThinkingLevel: 'high' },
60
+ ));
61
+
62
+ await waitFor(() => expect(inputActions.update).toHaveBeenCalledOnce());
63
+ const rollback = vi.mocked(inputActions.update).mock.calls[0]?.[0];
64
+ expect(typeof rollback).toBe('function');
65
+ expect((rollback as (current: never) => unknown)({
66
+ selectedModel: 'openai/gpt-5.6',
67
+ selectedThinkingLevel: 'off',
68
+ } as never)).toEqual({ selectedThinkingLevel: 'high' });
69
+ });
70
+
71
+ it('rolls back a failed runtime-default model selection using its UI value', async () => {
72
+ mocks.updateSession.mockRejectedValueOnce(new Error('save failed'));
73
+ const inputActions = createInputActions();
74
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
75
+ inputActions,
76
+ selectedSessionKey: 'session-1',
77
+ }));
78
+
79
+ act(() => result.current(
80
+ { preferredModel: null },
81
+ { selectedModel: '__runtime_default__' },
82
+ { selectedModel: 'openai/gpt-5.6' },
83
+ ));
84
+
85
+ await waitFor(() => expect(inputActions.update).toHaveBeenCalledOnce());
86
+ const rollback = vi.mocked(inputActions.update).mock.calls[0]?.[0];
87
+ expect((rollback as (current: never) => unknown)({
88
+ selectedModel: '__runtime_default__',
89
+ selectedThinkingLevel: 'high',
90
+ } as never)).toEqual({ selectedModel: 'openai/gpt-5.6' });
91
+ });
92
+
93
+ it('does not write a draft preference to a remote session', async () => {
94
+ const inputActions = createInputActions();
95
+ const { result } = renderHook(() => useSessionConversationPreferencePersistence({
96
+ inputActions,
97
+ selectedSessionKey: null,
98
+ }));
99
+
100
+ act(() => result.current(
101
+ { preferredThinking: 'off' },
102
+ { selectedThinkingLevel: 'off' },
103
+ { selectedThinkingLevel: 'high' },
104
+ ));
105
+ await Promise.resolve();
106
+
107
+ expect(mocks.updateSession).not.toHaveBeenCalled();
108
+ expect(inputActions.update).not.toHaveBeenCalled();
109
+ });
110
+ });
@@ -196,7 +196,7 @@ function usePersistedConversationInputSnapshot(
196
196
  });
197
197
  }, [draftKey, initialSnapshot]);
198
198
 
199
- return { snapshot, update };
199
+ return { draftKey, snapshot, update };
200
200
  }
201
201
 
202
202
  export const useSessionConversationInputState = (
@@ -204,7 +204,7 @@ export const useSessionConversationInputState = (
204
204
  sessionKey: string | null = null,
205
205
  ) => {
206
206
  const pendingProjectRoot = useChatThreadStore((state) => state.snapshot.draftProjectRoot);
207
- const { snapshot, update } = usePersistedConversationInputSnapshot(
207
+ const { draftKey, snapshot, update } = usePersistedConversationInputSnapshot(
208
208
  initialPrompt,
209
209
  sessionKey,
210
210
  );
@@ -268,6 +268,7 @@ export const useSessionConversationInputState = (
268
268
  });
269
269
 
270
270
  const preferenceActions = useSessionConversationPreferenceActions({
271
+ preferenceOwnerKey: draftKey,
271
272
  selectedModel: snapshot.selectedModel,
272
273
  selectedThinkingLevel: snapshot.selectedThinkingLevel,
273
274
  updatePreferences: update,
@@ -22,6 +22,7 @@ export type SessionConversationPreferenceSyncParams = {
22
22
  };
23
23
 
24
24
  type SessionConversationPreferenceActionsParams = {
25
+ readonly preferenceOwnerKey: string;
25
26
  readonly selectedModel: ConversationPreferenceValue;
26
27
  readonly selectedThinkingLevel: ThinkingLevel | null;
27
28
  readonly updatePreferences: (patch: {
@@ -31,14 +32,23 @@ type SessionConversationPreferenceActionsParams = {
31
32
  };
32
33
 
33
34
  export const useSessionConversationPreferenceActions = ({
35
+ preferenceOwnerKey,
34
36
  selectedModel: currentSelectedModel,
35
37
  selectedThinkingLevel: currentSelectedThinkingLevel,
36
38
  updatePreferences,
37
39
  }: SessionConversationPreferenceActionsParams) => {
38
40
  const previousPreferenceContextRef = useRef<string | undefined>(undefined);
41
+ const localThinkingSelectionRef = useRef<{
42
+ readonly ownerKey: string;
43
+ readonly value: ThinkingLevel | null;
44
+ } | null>(null);
39
45
  const setSelectedThinkingLevel = useCallback((selectedThinkingLevel: ThinkingLevel | null) => {
46
+ localThinkingSelectionRef.current = {
47
+ ownerKey: preferenceOwnerKey,
48
+ value: selectedThinkingLevel,
49
+ };
40
50
  updatePreferences({ selectedThinkingLevel });
41
- }, [updatePreferences]);
51
+ }, [preferenceOwnerKey, updatePreferences]);
42
52
  const syncSessionPreferences = useCallback(({
43
53
  defaultModel,
44
54
  fallbackPreferredModel,
@@ -54,6 +64,17 @@ export const useSessionConversationPreferenceActions = ({
54
64
  const preferenceContextKey = JSON.stringify([selectedSessionType, selectedSessionKey]);
55
65
  const preferenceContextChanged = previousPreferenceContextRef.current !== preferenceContextKey;
56
66
  const preserveCurrentPreference = preferenceContextChanged && Boolean(selectedSessionKey) && !selectedSessionExists;
67
+ const localThinkingSelection = localThinkingSelectionRef.current;
68
+ const localThinkingSelectionAcknowledged =
69
+ localThinkingSelection?.ownerKey === preferenceOwnerKey &&
70
+ selectedSessionExists &&
71
+ selectedSessionPreferredThinking === localThinkingSelection.value;
72
+ if (localThinkingSelectionAcknowledged) {
73
+ localThinkingSelectionRef.current = null;
74
+ }
75
+ const preserveLocalThinkingSelection =
76
+ localThinkingSelection?.ownerKey === preferenceOwnerKey &&
77
+ !localThinkingSelectionAcknowledged;
57
78
  const selectedModel = resolveSelectedModelValue({
58
79
  currentSelectedModel: currentSelectedModel ?? undefined,
59
80
  modelOptions,
@@ -73,7 +94,8 @@ export const useSessionConversationPreferenceActions = ({
73
94
  defaultThinkingLevel:
74
95
  (modelOption?.thinkingCapability?.default as ThinkingLevel | null | undefined) ?? null,
75
96
  preferSessionPreferredThinking: preferenceContextChanged,
76
- preserveCurrentSelectedThinkingOnSessionChange: preserveCurrentPreference,
97
+ preserveCurrentSelectedThinkingOnSessionChange:
98
+ preserveCurrentPreference || preserveLocalThinkingSelection,
77
99
  });
78
100
  if (
79
101
  currentSelectedModel !== selectedModel ||
@@ -84,7 +106,12 @@ export const useSessionConversationPreferenceActions = ({
84
106
  if (!selectedSessionKey || selectedSessionExists) {
85
107
  previousPreferenceContextRef.current = preferenceContextKey;
86
108
  }
87
- }, [currentSelectedModel, currentSelectedThinkingLevel, updatePreferences]);
109
+ }, [
110
+ currentSelectedModel,
111
+ currentSelectedThinkingLevel,
112
+ preferenceOwnerKey,
113
+ updatePreferences,
114
+ ]);
88
115
 
89
116
  return useMemo(() => ({
90
117
  setSelectedThinkingLevel,
@@ -0,0 +1,88 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ import type { SessionPatchUpdate, ThinkingLevel } from '@/shared/lib/api';
4
+ import { useChatSessionUpdate } from '@/features/chat/features/session/hooks/use-chat-session-update';
5
+ import type {
6
+ SessionConversationInputActions,
7
+ SessionConversationInputSnapshot,
8
+ } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
9
+
10
+ type SessionPreferencePatch = Pick<
11
+ SessionPatchUpdate,
12
+ 'preferredModel' | 'preferredThinking'
13
+ >;
14
+
15
+ type SessionPreferenceRollback = Pick<
16
+ SessionConversationInputSnapshot,
17
+ 'selectedModel' | 'selectedThinkingLevel'
18
+ >;
19
+
20
+ function ownsField<T extends object>(value: T, field: keyof T): boolean {
21
+ return Object.prototype.hasOwnProperty.call(value, field);
22
+ }
23
+
24
+ export function useSessionConversationPreferencePersistence(params: {
25
+ readonly inputActions: SessionConversationInputActions;
26
+ readonly selectedSessionKey: string | null;
27
+ }) {
28
+ const { inputActions, selectedSessionKey } = params;
29
+ const updateSession = useChatSessionUpdate();
30
+ const mutationSequenceRef = useRef(0);
31
+ const latestMutationByFieldRef = useRef(new Map<string, number>());
32
+ const persistenceQueueRef = useRef<Promise<void>>(Promise.resolve());
33
+
34
+ return useCallback((
35
+ patch: SessionPreferencePatch,
36
+ optimistic: Partial<SessionPreferenceRollback>,
37
+ rollback: Partial<SessionPreferenceRollback>,
38
+ ) => {
39
+ const sessionKey = selectedSessionKey?.trim();
40
+ if (!sessionKey) {
41
+ return;
42
+ }
43
+
44
+ const mutationId = ++mutationSequenceRef.current;
45
+ const modelMutationKey = `${sessionKey}:model`;
46
+ const thinkingMutationKey = `${sessionKey}:thinking`;
47
+ if (ownsField(patch, 'preferredModel')) {
48
+ latestMutationByFieldRef.current.set(modelMutationKey, mutationId);
49
+ }
50
+ if (ownsField(patch, 'preferredThinking')) {
51
+ latestMutationByFieldRef.current.set(thinkingMutationKey, mutationId);
52
+ }
53
+
54
+ const operation = persistenceQueueRef.current.then(() => updateSession({
55
+ invalidateSessionSkills: false,
56
+ sessionKey,
57
+ patch,
58
+ successMessage: null,
59
+ }));
60
+ persistenceQueueRef.current = operation.catch(() => undefined);
61
+
62
+ void operation.catch(() => {
63
+ inputActions.update((current) => {
64
+ const next: {
65
+ selectedModel?: string | null | undefined;
66
+ selectedThinkingLevel?: ThinkingLevel | null;
67
+ } = {};
68
+ if (
69
+ ownsField(patch, 'preferredModel') &&
70
+ latestMutationByFieldRef.current.get(modelMutationKey) === mutationId &&
71
+ current.selectedModel === optimistic.selectedModel &&
72
+ ownsField(rollback, 'selectedModel')
73
+ ) {
74
+ next.selectedModel = rollback.selectedModel;
75
+ }
76
+ if (
77
+ ownsField(patch, 'preferredThinking') &&
78
+ latestMutationByFieldRef.current.get(thinkingMutationKey) === mutationId &&
79
+ current.selectedThinkingLevel === optimistic.selectedThinkingLevel &&
80
+ ownsField(rollback, 'selectedThinkingLevel')
81
+ ) {
82
+ next.selectedThinkingLevel = rollback.selectedThinkingLevel;
83
+ }
84
+ return next;
85
+ });
86
+ });
87
+ }, [inputActions, selectedSessionKey, updateSession]);
88
+ }
@@ -71,4 +71,31 @@ describe('useChatSessionUpdate', () => {
71
71
  });
72
72
  expect(toast.success).toHaveBeenCalledWith('Project directory updated');
73
73
  });
74
+
75
+ it('can update an inline preference without showing a success toast', async () => {
76
+ const queryClient = new QueryClient();
77
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
78
+ mocks.updateNcpSession.mockResolvedValue({
79
+ sessionId: 'session-1',
80
+ updatedAt: '2026-08-15T00:00:00.000Z',
81
+ status: 'idle',
82
+ metadata: { preferred_thinking: 'off' },
83
+ });
84
+ const { result } = renderHook(() => useChatSessionUpdate(), {
85
+ wrapper: createWrapper(queryClient),
86
+ });
87
+
88
+ await act(async () => {
89
+ await result.current({
90
+ invalidateSessionSkills: false,
91
+ sessionKey: 'session-1',
92
+ patch: { preferredThinking: 'off' },
93
+ successMessage: null,
94
+ });
95
+ });
96
+
97
+ expect(toast.success).not.toHaveBeenCalled();
98
+ expect(mocks.upsertNcpSessionSummaryInQueryClient).toHaveBeenCalledOnce();
99
+ expect(invalidateQueriesSpy).not.toHaveBeenCalled();
100
+ });
74
101
  });
@@ -1,3 +1,4 @@
1
+ import { useCallback } from 'react';
1
2
  import { useQueryClient } from '@tanstack/react-query';
2
3
  import { toast } from 'sonner';
3
4
  import type { SessionPatchUpdate } from '@/shared/lib/api';
@@ -6,9 +7,10 @@ import { upsertNcpSessionSummaryInQueryClient } from '@/shared/lib/api';
6
7
  import { t } from '@/shared/lib/i18n';
7
8
 
8
9
  type UpdateChatSessionParams = {
10
+ invalidateSessionSkills?: boolean;
9
11
  sessionKey: string;
10
12
  patch: SessionPatchUpdate;
11
- successMessage?: string;
13
+ successMessage?: string | null;
12
14
  };
13
15
 
14
16
  type UpdateChatSessionLabelParams = {
@@ -19,18 +21,27 @@ type UpdateChatSessionLabelParams = {
19
21
  export function useChatSessionUpdate() {
20
22
  const queryClient = useQueryClient();
21
23
 
22
- return async (params: UpdateChatSessionParams): Promise<void> => {
23
- const { sessionKey, patch, successMessage } = params;
24
+ return useCallback(async (params: UpdateChatSessionParams): Promise<void> => {
25
+ const {
26
+ invalidateSessionSkills = true,
27
+ sessionKey,
28
+ patch,
29
+ successMessage,
30
+ } = params;
24
31
  try {
25
32
  const updated = await updateNcpSession(sessionKey, patch);
26
33
  upsertNcpSessionSummaryInQueryClient(queryClient, updated);
27
- await queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', sessionKey] });
28
- toast.success(successMessage ?? t('configSavedApplied'));
34
+ if (invalidateSessionSkills) {
35
+ await queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', sessionKey] });
36
+ }
37
+ if (successMessage !== null) {
38
+ toast.success(successMessage ?? t('configSavedApplied'));
39
+ }
29
40
  } catch (error) {
30
41
  toast.error(t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)));
31
42
  throw error;
32
43
  }
33
- };
44
+ }, [queryClient]);
34
45
  }
35
46
 
36
47
  export function useChatSessionLabel() {
@@ -1 +0,0 @@
1
- import{Mt as e}from"./index-L58BO782.js";export{e as RemoteAccessPage};