@nextclaw/ui 0.18.0 → 0.18.2
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 +17 -0
- package/dist/assets/{api-BJioGtWS.js → api-BSsQ0I4l.js} +1 -1
- package/dist/assets/{appearance-settings-page-DAF1fd14.js → appearance-settings-page-2ilQwMl0.js} +1 -1
- package/dist/assets/{book-open-aDv6R1av.js → book-open-BOJLGVsx.js} +1 -1
- package/dist/assets/{channels-list-page-Dubt8r9S.js → channels-list-page-BPy2mXoD.js} +1 -1
- package/dist/assets/{chat-page-JbwjeUo4.js → chat-page-DDR-Q4Kf.js} +1 -1
- package/dist/assets/{config-split-page-Bs1wArsX.js → config-split-page-aUTKtWJu.js} +1 -1
- package/dist/assets/{confirm-dialog-BFatCZUq.js → confirm-dialog-Bsg9A62_.js} +1 -1
- package/dist/assets/{desktop-update-config-DFefhK96.js → desktop-update-config-Ds4pN7kz.js} +1 -1
- package/dist/assets/{dist-CV9wIyME.js → dist-DyUY9uQ0.js} +1 -1
- package/dist/assets/{dist-D1I3blO_.js → dist-IBUY9_9h.js} +1 -1
- package/dist/assets/doc-browser-D0arfzHY.js +1 -0
- package/dist/assets/{doc-browser-BFfstBoh.js → doc-browser-D73yN8ts.js} +1 -1
- package/dist/assets/{doc-browser-context-BcwcVwX_.js → doc-browser-context-BM6Af8gN.js} +1 -1
- package/dist/assets/{form-actions-YSnn7q_U.js → form-actions-Cpd7glh8.js} +1 -1
- package/dist/assets/index-C2N1bYyb.js +109 -0
- package/dist/assets/loader-circle-C1fL3kBZ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-ZTHpQTvi.js → mcp-marketplace-page-D8V0GU4Y.js} +1 -1
- package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +1 -0
- package/dist/assets/{model-config-page-jYCwpq4U.js → model-config-page-B7QR33ED.js} +1 -1
- package/dist/assets/{navigation-link-7oSjAC9R.js → navigation-link-DtPZZ_Q_.js} +1 -1
- package/dist/assets/plus-B9V7Z_Eu.js +1 -0
- package/dist/assets/{provider-scoped-model-input-C1CUHVJx.js → provider-scoped-model-input-RWZz36z7.js} +1 -1
- package/dist/assets/{providers-config-page-CMvI4R1e.js → providers-config-page-B5yakiZe.js} +1 -1
- package/dist/assets/{react-7zG0fz1I.js → react-D-jOo51s.js} +1 -1
- package/dist/assets/{refresh-cw-BUg7hI3u.js → refresh-cw-BQHORcAs.js} +1 -1
- package/dist/assets/remote-BfRYDhkz.js +1 -0
- package/dist/assets/rotate-cw-DuCF-3PX.js +1 -0
- package/dist/assets/{runtime-config-page-DYuYv5yM.js → runtime-config-page-Big45YWb.js} +1 -1
- package/dist/assets/{save-CTCYuZqu.js → save-BgiS2u-T.js} +1 -1
- package/dist/assets/{search-CfFUpaf7.js → search-B6mO7mmo.js} +1 -1
- package/dist/assets/{search-config-page-GUtGbk6i.js → search-config-page-QS-kqNDZ.js} +1 -1
- package/dist/assets/{secrets-config-page-Delo8ab0.js → secrets-config-page-DE3kmDHk.js} +1 -1
- package/dist/assets/security-config-Ds_2SXBh.js +1 -0
- package/dist/assets/{select-MSvkWmPP.js → select-DToos7Rq.js} +1 -1
- package/dist/assets/{settings-2-DXUfAWgg.js → settings-2-ZAWXrq81.js} +1 -1
- package/dist/assets/skeleton-Bs2eO2CA.js +1 -0
- package/dist/assets/{tag-chip-u8g0b0Nf.js → tag-chip-urB3N-kk.js} +1 -1
- package/dist/assets/x-BkjTUiww.js +1 -0
- package/dist/index.html +19 -19
- package/package.json +3 -3
- package/src/features/apps/components/app-marketplace-catalog.tsx +3 -0
- package/src/features/apps/components/app-marketplace-detail.tsx +7 -1
- package/src/features/apps/services/app-marketplace.service.ts +11 -0
- package/src/features/apps/types/app-marketplace.types.ts +5 -0
- package/src/features/apps/utils/app-marketplace-platform.utils.ts +19 -0
- 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/input/utils/chat-input-toolbar.utils.ts +2 -13
- 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/src/features/chat/features/session/utils/chat-session-preference-governance.utils.ts +4 -2
- package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +11 -0
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +2 -0
- package/src/shared/lib/provider-models/index.ts +11 -0
- package/dist/assets/doc-browser-DEdoMb3X.js +0 -1
- package/dist/assets/index-L58BO782.js +0 -109
- package/dist/assets/loader-circle-CsNiT6VM.js +0 -1
- package/dist/assets/mcp-marketplace-page-DfUCF-L5.js +0 -1
- package/dist/assets/plus-D6UN8XN5.js +0 -1
- package/dist/assets/remote-BYPq49kD.js +0 -1
- package/dist/assets/rotate-cw-B3qmXtfJ.js +0 -1
- package/dist/assets/security-config-DoVoel5I.js +0 -1
- package/dist/assets/skeleton-DDFivi9Y.js +0 -1
- package/dist/assets/x-DdOEUmR4.js +0 -1
|
@@ -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: ['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 hydration overwrite explicit off when the provider only declares active levels', () => {
|
|
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
|
+
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ChatThinkingLevel,
|
|
9
9
|
} from "@/features/chat/types/chat-input-bar.types";
|
|
10
10
|
import type { ChatModelOption } from "@/features/chat/types/chat-input.types";
|
|
11
|
+
import { resolveSelectableThinkingLevels } from "@/shared/lib/provider-models";
|
|
11
12
|
|
|
12
13
|
function formatModelOptionLabel(option: ChatModelRecord): string {
|
|
13
14
|
const modelLabel = option.modelLabel.trim();
|
|
@@ -44,18 +45,6 @@ export function toChatModelRecords(snapshotModels: ChatModelOption[]): ChatModel
|
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function normalizeThinkingLevels(
|
|
48
|
-
levels: ChatThinkingLevel[],
|
|
49
|
-
): ChatThinkingLevel[] {
|
|
50
|
-
const deduped: ChatThinkingLevel[] = [];
|
|
51
|
-
for (const level of ["off", ...levels] as ChatThinkingLevel[]) {
|
|
52
|
-
if (!deduped.includes(level)) {
|
|
53
|
-
deduped.push(level);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return deduped;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
48
|
export function buildModelStateHint(params: {
|
|
60
49
|
isModelOptionsEmpty: boolean;
|
|
61
50
|
onGoToProviders: () => void;
|
|
@@ -284,7 +273,7 @@ export function buildThinkingToolbarSelect(params: {
|
|
|
284
273
|
return null;
|
|
285
274
|
}
|
|
286
275
|
|
|
287
|
-
const options =
|
|
276
|
+
const options = resolveSelectableThinkingLevels(supportedLevels);
|
|
288
277
|
const fallback = options.includes("off") ? "off" : options[0];
|
|
289
278
|
const resolvedValue =
|
|
290
279
|
(selectedThinkingLevel &&
|
|
@@ -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() {
|
package/src/features/chat/features/session/utils/chat-session-preference-governance.utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionEntryView, ThinkingLevel } from '@/shared/lib/api';
|
|
2
2
|
import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
|
|
3
|
+
import { resolveSelectableThinkingLevels } from '@/shared/lib/provider-models';
|
|
3
4
|
|
|
4
5
|
function normalizeSessionType(value: string | null | undefined): string {
|
|
5
6
|
return value?.trim().toLowerCase() || 'native';
|
|
@@ -115,13 +116,14 @@ export function resolveSelectedThinkingLevelValue(params: {
|
|
|
115
116
|
if (supportedThinkingLevels.length === 0) {
|
|
116
117
|
return null;
|
|
117
118
|
}
|
|
119
|
+
const selectableThinkingLevels = resolveSelectableThinkingLevels(supportedThinkingLevels);
|
|
118
120
|
return resolveSessionPreferenceValue<ThinkingLevel>({
|
|
119
121
|
currentValue: currentSelectedThinkingLevel,
|
|
120
122
|
selectedSessionPreferredValue: selectedSessionPreferredThinking,
|
|
121
123
|
fallbackPreferredValue: fallbackPreferredThinking,
|
|
122
124
|
defaultValue: defaultThinkingLevel,
|
|
123
|
-
isValueSupported: (value): value is ThinkingLevel => hasThinkingLevelOption(
|
|
124
|
-
firstAvailableValue: resolveFallbackThinkingLevel(
|
|
125
|
+
isValueSupported: (value): value is ThinkingLevel => hasThinkingLevelOption(selectableThinkingLevels, value),
|
|
126
|
+
firstAvailableValue: resolveFallbackThinkingLevel(selectableThinkingLevels) ?? 'off',
|
|
125
127
|
preferSessionPreferredValue: preferSessionPreferredThinking,
|
|
126
128
|
preserveCurrentValueOnSessionChange: preserveCurrentSelectedThinkingOnSessionChange
|
|
127
129
|
});
|
|
@@ -539,6 +539,17 @@ describe('resolveRecentSessionPreferredModel', () => {
|
|
|
539
539
|
});
|
|
540
540
|
|
|
541
541
|
describe('resolveSelectedThinkingLevelValue', () => {
|
|
542
|
+
it('keeps explicit off when provider capabilities only declare active levels', () => {
|
|
543
|
+
expect(
|
|
544
|
+
resolveSelectedThinkingLevelValue({
|
|
545
|
+
currentSelectedThinkingLevel: 'off',
|
|
546
|
+
supportedThinkingLevels: ['low', 'medium', 'high'],
|
|
547
|
+
fallbackPreferredThinking: 'high',
|
|
548
|
+
defaultThinkingLevel: 'medium'
|
|
549
|
+
})
|
|
550
|
+
).toBe('off');
|
|
551
|
+
});
|
|
552
|
+
|
|
542
553
|
it('keeps the current selected thinking when it is still valid', () => {
|
|
543
554
|
expect(
|
|
544
555
|
resolveSelectedThinkingLevelValue({
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
"appPackagesBuiltIn": "Built into NextClaw",
|
|
67
67
|
"appPackagesLocalService": "Local data service",
|
|
68
68
|
"appPackagesIsolationLabel": "Runtime isolation",
|
|
69
|
+
"appPackagesPlatformsLabel": "Supported platforms",
|
|
70
|
+
"appPackagesAllPlatforms": "All platforms",
|
|
69
71
|
"appPackagesIsolationPanel": "Isolated interface",
|
|
70
72
|
"appPackagesIsolationMediated": "Host-mediated runtime",
|
|
71
73
|
"appPackagesIsolationFullUser": "Runs with your user permissions",
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
"appPackagesBuiltIn": "NextClaw 内置",
|
|
67
67
|
"appPackagesLocalService": "本地数据服务",
|
|
68
68
|
"appPackagesIsolationLabel": "运行隔离",
|
|
69
|
+
"appPackagesPlatformsLabel": "支持平台",
|
|
70
|
+
"appPackagesAllPlatforms": "全部平台",
|
|
69
71
|
"appPackagesIsolationPanel": "隔离界面",
|
|
70
72
|
"appPackagesIsolationMediated": "受限运行",
|
|
71
73
|
"appPackagesIsolationFullUser": "以当前用户权限运行",
|
|
@@ -108,6 +108,17 @@ export function normalizeThinkingLevels(values: unknown): ThinkingLevel[] {
|
|
|
108
108
|
return deduped;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Provider capabilities describe active thinking efforts. NextClaw owns the
|
|
113
|
+
* explicit off state, so every declared thinking capability exposes it to chat
|
|
114
|
+
* consumers even when the provider does not repeat it in `supported`.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveSelectableThinkingLevels(
|
|
117
|
+
supported: readonly ThinkingLevel[]
|
|
118
|
+
): ThinkingLevel[] {
|
|
119
|
+
return supported.includes('off') ? [...supported] : ['off', ...supported];
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
export function normalizeModelConfigMap(
|
|
112
123
|
input: ProviderConfigView['modelConfig'],
|
|
113
124
|
aliases: string[]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./doc-browser-BFfstBoh.js";export{e as DocBrowser};
|