@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.
- package/CHANGELOG.md +7 -0
- package/dist/assets/{appearance-settings-page-DAF1fd14.js → appearance-settings-page-CR2LJW8I.js} +1 -1
- package/dist/assets/{channels-list-page-Dubt8r9S.js → channels-list-page-BXyx_gxw.js} +1 -1
- package/dist/assets/{chat-page-JbwjeUo4.js → chat-page-OP5V5mAm.js} +1 -1
- package/dist/assets/{desktop-update-config-DFefhK96.js → desktop-update-config-eb7REelt.js} +1 -1
- package/dist/assets/{index-L58BO782.js → index-DotCWGD1.js} +51 -51
- package/dist/assets/{model-config-page-jYCwpq4U.js → model-config-page-BOhaenKN.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-C1CUHVJx.js → provider-scoped-model-input-DlohNUbq.js} +1 -1
- package/dist/assets/{providers-config-page-CMvI4R1e.js → providers-config-page-eYaDPENc.js} +1 -1
- package/dist/assets/remote-Cx_8UTi5.js +1 -0
- package/dist/assets/{runtime-config-page-DYuYv5yM.js → runtime-config-page-D5lV05VE.js} +1 -1
- package/dist/assets/{search-config-page-GUtGbk6i.js → search-config-page-DAWe6n7E.js} +1 -1
- package/dist/assets/{secrets-config-page-Delo8ab0.js → secrets-config-page-DyFeZDAi.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +100 -1
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +17 -17
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +31 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-preference-persistence.test.tsx +110 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +3 -2
- package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +30 -3
- package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence.ts +88 -0
- package/src/features/chat/features/session/hooks/__tests__/use-chat-session-update.test.tsx +27 -0
- package/src/features/chat/features/session/hooks/use-chat-session-update.ts +17 -6
- 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:
|
|
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 {
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
+
});
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts
CHANGED
|
@@ -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,
|
package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts
CHANGED
|
@@ -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:
|
|
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
|
-
}, [
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
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};
|