@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assets/{api-BJioGtWS.js → api-BSsQ0I4l.js} +1 -1
  3. package/dist/assets/{appearance-settings-page-DAF1fd14.js → appearance-settings-page-2ilQwMl0.js} +1 -1
  4. package/dist/assets/{book-open-aDv6R1av.js → book-open-BOJLGVsx.js} +1 -1
  5. package/dist/assets/{channels-list-page-Dubt8r9S.js → channels-list-page-BPy2mXoD.js} +1 -1
  6. package/dist/assets/{chat-page-JbwjeUo4.js → chat-page-DDR-Q4Kf.js} +1 -1
  7. package/dist/assets/{config-split-page-Bs1wArsX.js → config-split-page-aUTKtWJu.js} +1 -1
  8. package/dist/assets/{confirm-dialog-BFatCZUq.js → confirm-dialog-Bsg9A62_.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DFefhK96.js → desktop-update-config-Ds4pN7kz.js} +1 -1
  10. package/dist/assets/{dist-CV9wIyME.js → dist-DyUY9uQ0.js} +1 -1
  11. package/dist/assets/{dist-D1I3blO_.js → dist-IBUY9_9h.js} +1 -1
  12. package/dist/assets/doc-browser-D0arfzHY.js +1 -0
  13. package/dist/assets/{doc-browser-BFfstBoh.js → doc-browser-D73yN8ts.js} +1 -1
  14. package/dist/assets/{doc-browser-context-BcwcVwX_.js → doc-browser-context-BM6Af8gN.js} +1 -1
  15. package/dist/assets/{form-actions-YSnn7q_U.js → form-actions-Cpd7glh8.js} +1 -1
  16. package/dist/assets/index-C2N1bYyb.js +109 -0
  17. package/dist/assets/loader-circle-C1fL3kBZ.js +1 -0
  18. package/dist/assets/{mcp-marketplace-page-ZTHpQTvi.js → mcp-marketplace-page-D8V0GU4Y.js} +1 -1
  19. package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +1 -0
  20. package/dist/assets/{model-config-page-jYCwpq4U.js → model-config-page-B7QR33ED.js} +1 -1
  21. package/dist/assets/{navigation-link-7oSjAC9R.js → navigation-link-DtPZZ_Q_.js} +1 -1
  22. package/dist/assets/plus-B9V7Z_Eu.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-C1CUHVJx.js → provider-scoped-model-input-RWZz36z7.js} +1 -1
  24. package/dist/assets/{providers-config-page-CMvI4R1e.js → providers-config-page-B5yakiZe.js} +1 -1
  25. package/dist/assets/{react-7zG0fz1I.js → react-D-jOo51s.js} +1 -1
  26. package/dist/assets/{refresh-cw-BUg7hI3u.js → refresh-cw-BQHORcAs.js} +1 -1
  27. package/dist/assets/remote-BfRYDhkz.js +1 -0
  28. package/dist/assets/rotate-cw-DuCF-3PX.js +1 -0
  29. package/dist/assets/{runtime-config-page-DYuYv5yM.js → runtime-config-page-Big45YWb.js} +1 -1
  30. package/dist/assets/{save-CTCYuZqu.js → save-BgiS2u-T.js} +1 -1
  31. package/dist/assets/{search-CfFUpaf7.js → search-B6mO7mmo.js} +1 -1
  32. package/dist/assets/{search-config-page-GUtGbk6i.js → search-config-page-QS-kqNDZ.js} +1 -1
  33. package/dist/assets/{secrets-config-page-Delo8ab0.js → secrets-config-page-DE3kmDHk.js} +1 -1
  34. package/dist/assets/security-config-Ds_2SXBh.js +1 -0
  35. package/dist/assets/{select-MSvkWmPP.js → select-DToos7Rq.js} +1 -1
  36. package/dist/assets/{settings-2-DXUfAWgg.js → settings-2-ZAWXrq81.js} +1 -1
  37. package/dist/assets/skeleton-Bs2eO2CA.js +1 -0
  38. package/dist/assets/{tag-chip-u8g0b0Nf.js → tag-chip-urB3N-kk.js} +1 -1
  39. package/dist/assets/x-BkjTUiww.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +3 -3
  42. package/src/features/apps/components/app-marketplace-catalog.tsx +3 -0
  43. package/src/features/apps/components/app-marketplace-detail.tsx +7 -1
  44. package/src/features/apps/services/app-marketplace.service.ts +11 -0
  45. package/src/features/apps/types/app-marketplace.types.ts +5 -0
  46. package/src/features/apps/utils/app-marketplace-platform.utils.ts +19 -0
  47. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +100 -1
  48. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +17 -17
  49. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +31 -0
  50. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-preference-persistence.test.tsx +110 -0
  51. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +3 -2
  52. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +30 -3
  53. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-persistence.ts +88 -0
  54. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +2 -13
  55. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-update.test.tsx +27 -0
  56. package/src/features/chat/features/session/hooks/use-chat-session-update.ts +17 -6
  57. package/src/features/chat/features/session/utils/chat-session-preference-governance.utils.ts +4 -2
  58. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +11 -0
  59. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +2 -0
  60. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +2 -0
  61. package/src/shared/lib/provider-models/index.ts +11 -0
  62. package/dist/assets/doc-browser-DEdoMb3X.js +0 -1
  63. package/dist/assets/index-L58BO782.js +0 -109
  64. package/dist/assets/loader-circle-CsNiT6VM.js +0 -1
  65. package/dist/assets/mcp-marketplace-page-DfUCF-L5.js +0 -1
  66. package/dist/assets/plus-D6UN8XN5.js +0 -1
  67. package/dist/assets/remote-BYPq49kD.js +0 -1
  68. package/dist/assets/rotate-cw-B3qmXtfJ.js +0 -1
  69. package/dist/assets/security-config-DoVoel5I.js +0 -1
  70. package/dist/assets/skeleton-DDFivi9Y.js +0 -1
  71. 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 { 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: ['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
+ });
@@ -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
+ }
@@ -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 = normalizeThinkingLevels(supportedLevels);
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 { 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,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(supportedThinkingLevels, value),
124
- firstAvailableValue: resolveFallbackThinkingLevel(supportedThinkingLevels) ?? 'off',
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};