@nextclaw/ui 0.14.1-beta.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-C6EDHBDR.js → api-DPcvCLfa.js} +3 -3
  3. package/dist/assets/{channels-list-page-DKj6m1Hc.js → channels-list-page-D6jtziu4.js} +2 -2
  4. package/dist/assets/chat-page-BBnNJoKG.js +105 -0
  5. package/dist/assets/{config-split-page-CjlmJy8X.js → config-split-page-BCJhqdK9.js} +1 -1
  6. package/dist/assets/{confirm-dialog-B39X0pYp.js → confirm-dialog-CWh5FfK2.js} +1 -1
  7. package/dist/assets/{desktop-update-config-DfUQ8nly.js → desktop-update-config-DsRhBizi.js} +1 -1
  8. package/dist/assets/{dist-CmMro3nV.js → dist-DPrgcEC0.js} +1 -1
  9. package/dist/assets/{dist-BIYV2-Ia.js → dist-DsE4OsSS.js} +1 -1
  10. package/dist/assets/doc-browser-C8xOF_9F.js +1 -0
  11. package/dist/assets/{doc-browser-CDtUtcim.js → doc-browser-DgQedeAn.js} +1 -1
  12. package/dist/assets/{doc-browser-context-DZbC9zVB.js → doc-browser-context-B46tQGmO.js} +1 -1
  13. package/dist/assets/{ellipsis-DDpgTzgx.js → ellipsis-B81FStLm.js} +1 -1
  14. package/dist/assets/{external-link-D2cBOCqn.js → external-link-C_dmG-WJ.js} +1 -1
  15. package/dist/assets/index-7o1WdUbU.js +103 -0
  16. package/dist/assets/index-DQWmEWzk.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +40 -0
  19. package/dist/assets/model-config-page-Cv0O3BIj.js +1 -0
  20. package/dist/assets/plus-Bnt56oqG.js +1 -0
  21. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +1 -0
  22. package/dist/assets/providers-config-page-CPDz0Lxc.js +1 -0
  23. package/dist/assets/{react-DYQXXGbH.js → react-sZz5HSCm.js} +1 -1
  24. package/dist/assets/remote-B0LMs8u7.js +1 -0
  25. package/dist/assets/rotate-cw-BfyG_KEF.js +1 -0
  26. package/dist/assets/runtime-config-page-q0fvv8ME.js +1 -0
  27. package/dist/assets/{save-BNVTgBVn.js → save-MzjoiHri.js} +1 -1
  28. package/dist/assets/{search-CKCzwLId.js → search-B29dE4GN.js} +1 -1
  29. package/dist/assets/{search-config-page-DG5Q2hF1.js → search-config-page-Dl5sZzob.js} +1 -1
  30. package/dist/assets/secrets-config-page-C_l-IFCK.js +3 -0
  31. package/dist/assets/{select-CIRwTNP4.js → select-BKmh05hd.js} +2 -2
  32. package/dist/assets/{tag-chip-DKhVFtTE.js → tag-chip-BS_7Ueom.js} +1 -1
  33. package/dist/assets/use-config-DZkBWIdL.js +1 -0
  34. package/dist/assets/x-CW2KO-Hz.js +1 -0
  35. package/dist/index.html +14 -14
  36. package/package.json +9 -9
  37. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +12 -63
  38. package/src/features/chat/components/conversation/chat-conversation-content.tsx +11 -50
  39. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +15 -2
  40. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +70 -7
  41. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +104 -16
  42. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -2
  43. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +84 -6
  44. package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +2 -0
  45. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +88 -0
  46. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +64 -24
  47. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +1 -0
  48. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +21 -12
  49. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +80 -25
  50. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +310 -0
  51. package/src/features/chat/features/message/utils/__tests__/chat-message-test.utils.ts +43 -0
  52. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +1 -346
  53. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +10 -9
  54. package/src/features/chat/features/message/utils/file-operation/diff.utils.ts +39 -36
  55. package/src/features/chat/features/message/utils/file-operation/line-builder.utils.ts +46 -38
  56. package/src/features/chat/features/message/utils/file-operation/parsed-block.utils.ts +18 -15
  57. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +3 -2
  58. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +25 -25
  59. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +15 -2
  60. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +24 -0
  61. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +27 -1
  62. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +17 -4
  63. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +15 -3
  64. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  65. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  66. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +193 -0
  67. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +123 -0
  68. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +104 -0
  69. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +72 -0
  70. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +54 -0
  71. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +132 -0
  72. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +82 -0
  73. package/src/features/chat/features/welcome/components/chat-welcome.tsx +121 -0
  74. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +53 -0
  75. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-selection.utils.test.ts +63 -0
  76. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-visibility.utils.test.ts +34 -0
  77. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +71 -0
  78. package/src/features/chat/features/welcome/utils/chat-welcome-selection.utils.ts +79 -0
  79. package/src/features/chat/features/welcome/utils/chat-welcome-visibility.utils.ts +17 -0
  80. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +65 -0
  81. package/src/features/chat/managers/chat-input.manager.ts +24 -2
  82. package/src/features/chat/stores/chat-input.store.ts +2 -0
  83. package/src/features/chat/types/chat-input-bar.types.ts +5 -0
  84. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  85. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  86. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  87. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  88. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  89. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +27 -23
  90. package/src/features/settings/components/config/provider-form.tsx +284 -387
  91. package/src/features/settings/components/config/provider-status-badge.tsx +4 -4
  92. package/src/features/settings/components/config/secrets-config-form.tsx +11 -135
  93. package/src/features/settings/hooks/use-provider-auth-flow.ts +140 -0
  94. package/src/features/settings/pages/__tests__/model-config-page.test.tsx +16 -0
  95. package/src/features/settings/pages/secrets-config-page.tsx +2 -1
  96. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +60 -1
  97. package/src/features/settings/utils/__tests__/secrets-config-form.utils.test.ts +66 -0
  98. package/src/features/settings/utils/provider-form-context.utils.ts +131 -0
  99. package/src/features/settings/utils/provider-form-model.utils.ts +101 -0
  100. package/src/features/settings/utils/provider-form-support.utils.ts +102 -1
  101. package/src/features/settings/utils/secrets-config-form.utils.ts +139 -0
  102. package/src/features/system-status/components/config/runtime-agent-list-card.tsx +17 -11
  103. package/src/features/system-status/components/config/runtime-binding-list-card.tsx +14 -9
  104. package/src/features/system-status/components/config/runtime-config-editor.tsx +8 -5
  105. package/src/features/system-status/components/config/runtime-entry-list-card.tsx +14 -9
  106. package/src/features/system-status/components/config/runtime-settings-card.tsx +16 -7
  107. package/src/features/system-status/components/runtime-security-card.tsx +79 -25
  108. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  109. package/src/shared/components/common/__tests__/searchable-model-input.test.tsx +34 -0
  110. package/src/shared/components/common/agent-avatar.tsx +5 -2
  111. package/src/shared/components/common/provider-scoped-model-input.tsx +1 -0
  112. package/src/shared/components/common/searchable-model-input.tsx +102 -87
  113. package/src/shared/components/ui/README.md +7 -0
  114. package/src/shared/components/ui/popover.tsx +19 -3
  115. package/src/shared/components/ui/select.tsx +14 -4
  116. package/src/shared/hooks/use-infinite-scroll-loader.ts +14 -10
  117. package/src/shared/lib/api/index.ts +3 -0
  118. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  119. package/src/shared/lib/api/preferences/preference.types.ts +22 -0
  120. package/src/shared/lib/api/preferences/preference.utils.ts +40 -0
  121. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
  122. package/src/shared/lib/i18n/locales/en-US/core.json +1 -0
  123. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  124. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
  125. package/src/shared/lib/i18n/locales/zh-CN/core.json +1 -0
  126. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  127. package/dist/assets/chat-page-BXngLJY6.js +0 -105
  128. package/dist/assets/doc-browser-B6Wc_TIY.js +0 -1
  129. package/dist/assets/index-DTb_XVIH.js +0 -103
  130. package/dist/assets/index-ayiPYhf1.css +0 -1
  131. package/dist/assets/mcp-marketplace-page-DZ9H0v05.js +0 -1
  132. package/dist/assets/mcp-marketplace-page-cnVWf7QQ.js +0 -40
  133. package/dist/assets/model-config-page-Bcb35AVv.js +0 -1
  134. package/dist/assets/plus-CYE1onU2.js +0 -1
  135. package/dist/assets/provider-scoped-model-input-kdoNp4HG.js +0 -1
  136. package/dist/assets/providers-config-page-CQSibSwO.js +0 -1
  137. package/dist/assets/remote-BlJDbXOI.js +0 -1
  138. package/dist/assets/rotate-cw-BWbvFDAX.js +0 -1
  139. package/dist/assets/runtime-config-page-Tn2yLQ0c.js +0 -1
  140. package/dist/assets/secrets-config-page-BXn5Uuwp.js +0 -3
  141. package/dist/assets/use-config-DgKM9VIU.js +0 -1
  142. package/dist/assets/x-Cm3JjYut.js +0 -1
  143. package/src/features/chat/components/__tests__/chat-welcome.test.tsx +0 -45
  144. package/src/features/chat/components/chat-welcome.tsx +0 -102
@@ -13,6 +13,7 @@ import { chatRecentSkillsManager, CHAT_RECENT_SKILLS_MIN_OPTIONS } from '@/featu
13
13
  import { deriveSelectedSkillsFromComposer } from '@/features/chat/features/input/utils/chat-composer-state.utils';
14
14
  import { hasNcpChatModelOptions, isNcpChatComposerDisabled, isNcpChatModelOptionsEmpty, isNcpChatModelOptionsLoading, isNcpChatSendDisabled } from '@/features/chat/features/input/utils/ncp-chat-input-availability.utils';
15
15
  import { useChatInputBarQueryState } from '@/features/chat/features/input/hooks/use-chat-input-bar-query-state';
16
+ import { useChatModelFavorites } from '@/features/chat/features/input/hooks/use-chat-model-favorites';
16
17
  import { useSelectedSessionContextWindowIndicator } from '@/features/chat/features/session/hooks/use-selected-session-context-window-indicator';
17
18
  import { useSystemStatus } from '@/features/system-status';
18
19
  import { isNcpChatRuntimeBlocked } from '@/features/chat/features/runtime/utils/ncp-chat-runtime-availability.utils';
@@ -89,6 +90,11 @@ function useChatInputBarLabels(language: string) {
89
90
  slashTexts,
90
91
  recentModelsLabel: t('chatPickerRecentModels'),
91
92
  allModelsLabel: t('chatPickerAllModels'),
93
+ favoriteModelsLabel: t('chatPickerFavoriteModels'),
94
+ modelSearchPlaceholder: t('chatModelSearchPlaceholder'),
95
+ modelSearchEmptyLabel: t('chatModelSearchEmpty'),
96
+ favoriteModelLabel: t('chatFavoriteModel'),
97
+ unfavoriteModelLabel: t('chatUnfavoriteModel'),
92
98
  recentSkillsLabel: t('chatPickerRecent'),
93
99
  allSkillsLabel: t('chatPickerAllSkills')
94
100
  };
@@ -162,19 +168,69 @@ function useChatInputBarAttachments(params: { attachmentSupported: boolean; inpu
162
168
  }, [handleFilesAdd])
163
169
  };
164
170
  }
165
- function buildToolbarSelects(params: { allModelsLabel: string; hasModelOptions: boolean; isModelOptionsLoading: boolean; modelRecords: ChatModelRecord[]; onModelChange: (value: string) => void; onThinkingChange: (value: ChatThinkingLevel | null) => void; recentModelValues: string[]; recentModelsLabel: string; selectedModel: string; selectedThinkingLevel: ChatThinkingLevel | null; thinkingSupportedLevels: ChatThinkingLevel[]; thinkingDefaultLevel: ChatThinkingLevel | null; }) {
166
- const { allModelsLabel, hasModelOptions, isModelOptionsLoading, modelRecords, onModelChange, onThinkingChange, recentModelValues, recentModelsLabel, selectedModel, selectedThinkingLevel, thinkingSupportedLevels, thinkingDefaultLevel } = params;
171
+
172
+ type ToolbarSelectBuildParams = {
173
+ allModelsLabel: string;
174
+ favoriteModelLabel: string;
175
+ favoriteModelValues: string[];
176
+ favoriteModelsLabel: string;
177
+ hasModelOptions: boolean;
178
+ isModelOptionsLoading: boolean;
179
+ modelRecords: ChatModelRecord[];
180
+ modelSearchEmptyLabel: string;
181
+ modelSearchPlaceholder: string;
182
+ onFavoriteModelToggle: (value: string, favorite: boolean) => void;
183
+ onModelChange: (value: string) => void;
184
+ onThinkingChange: (value: ChatThinkingLevel | null) => void;
185
+ recentModelValues: string[];
186
+ recentModelsLabel: string;
187
+ selectedModel: string;
188
+ selectedThinkingLevel: ChatThinkingLevel | null;
189
+ thinkingDefaultLevel: ChatThinkingLevel | null;
190
+ thinkingSupportedLevels: ChatThinkingLevel[];
191
+ unfavoriteModelLabel: string;
192
+ };
193
+
194
+ function buildToolbarSelects(params: ToolbarSelectBuildParams) {
195
+ const {
196
+ allModelsLabel,
197
+ favoriteModelLabel,
198
+ favoriteModelValues,
199
+ favoriteModelsLabel,
200
+ hasModelOptions,
201
+ isModelOptionsLoading,
202
+ modelRecords,
203
+ modelSearchEmptyLabel,
204
+ modelSearchPlaceholder,
205
+ onFavoriteModelToggle,
206
+ onModelChange,
207
+ onThinkingChange,
208
+ recentModelValues,
209
+ recentModelsLabel,
210
+ selectedModel,
211
+ selectedThinkingLevel,
212
+ thinkingDefaultLevel,
213
+ thinkingSupportedLevels,
214
+ unfavoriteModelLabel
215
+ } = params;
167
216
  return [
168
217
  buildModelToolbarSelect({
169
218
  modelOptions: modelRecords,
219
+ favoriteModelValues,
170
220
  recentModelValues,
171
221
  selectedModel,
172
222
  isModelOptionsLoading,
173
223
  hasModelOptions,
224
+ onFavoriteToggle: onFavoriteModelToggle,
174
225
  onValueChange: onModelChange,
175
226
  texts: {
176
227
  modelSelectPlaceholder: t('chatSelectModel'),
177
228
  modelNoOptionsLabel: t('chatModelNoOptions'),
229
+ modelSearchPlaceholder,
230
+ modelSearchEmptyLabel,
231
+ favoriteModelsLabel,
232
+ favoriteModelLabel,
233
+ unfavoriteModelLabel,
178
234
  recentModelsLabel,
179
235
  allModelsLabel
180
236
  }
@@ -211,7 +267,11 @@ function buildSkillPicker(params: { allSkillsLabel: string; isSkillsLoading: boo
211
267
  });
212
268
  }
213
269
 
214
- export function ChatInputBarContainer() {
270
+ type ChatInputBarContainerProps = {
271
+ surface?: 'default' | 'embedded';
272
+ };
273
+
274
+ export function ChatInputBarContainer({ surface = 'default' }: ChatInputBarContainerProps) {
215
275
  const presenter = usePresenter();
216
276
  const { language } = useI18n();
217
277
  const { isMobile } = useViewportLayout();
@@ -227,6 +287,14 @@ export function ChatInputBarContainer() {
227
287
  skillRecords: inputQueryState.skillRecords,
228
288
  skillScopeLabels: labels.skillScopeLabels
229
289
  });
290
+ const modelRecordValues = useMemo(
291
+ () => modelRecords.map((option) => option.value),
292
+ [modelRecords]
293
+ );
294
+ const {
295
+ favoriteModelValues,
296
+ setModelFavorite,
297
+ } = useChatModelFavorites(modelRecordValues);
230
298
  const availabilitySnapshot = {
231
299
  ...snapshot,
232
300
  isProviderStateResolved: inputQueryState.isProviderStateResolved,
@@ -257,6 +325,7 @@ export function ChatInputBarContainer() {
257
325
  modelOptions: inputQueryState.modelOptions,
258
326
  skillRecords: inputQueryState.skillRecords,
259
327
  defaultSessionType: inputQueryState.sessionTypeState.defaultSessionType,
328
+ defaultProjectRoot: inputQueryState.defaultProjectRoot,
260
329
  sessionTypeOptions: inputQueryState.sessionTypeState.sessionTypeOptions,
261
330
  selectedSessionType: inputQueryState.sessionTypeState.selectedSessionType,
262
331
  canEditSessionType: inputQueryState.sessionTypeState.canEditSessionType,
@@ -274,6 +343,7 @@ export function ChatInputBarContainer() {
274
343
  });
275
344
  }, [
276
345
  inputQueryState.defaultModel,
346
+ inputQueryState.defaultProjectRoot,
277
347
  inputQueryState.fallbackPreferredModel,
278
348
  inputQueryState.fallbackPreferredThinking,
279
349
  inputQueryState.isProviderStateResolved,
@@ -295,15 +365,21 @@ export function ChatInputBarContainer() {
295
365
  return;
296
366
  }
297
367
  if (request.placement === 'end') {
298
- inputBarRef.current?.focusComposerAtEnd();
368
+ inputBarRef.current?.focusComposerAtEnd(snapshot.composerNodes);
299
369
  }
300
370
  presenter.chatInputManager.consumeComposerFocusRequest(request.id);
301
- }, [presenter.chatInputManager, snapshot.composerFocusRequest]);
371
+ }, [presenter.chatInputManager, snapshot.composerFocusRequest, snapshot.composerNodes]);
302
372
  const toolbarSelects = buildToolbarSelects({
303
373
  allModelsLabel: labels.allModelsLabel,
374
+ favoriteModelLabel: labels.favoriteModelLabel,
375
+ favoriteModelValues,
376
+ favoriteModelsLabel: labels.favoriteModelsLabel,
304
377
  hasModelOptions,
305
378
  isModelOptionsLoading,
306
379
  modelRecords,
380
+ modelSearchEmptyLabel: labels.modelSearchEmptyLabel,
381
+ modelSearchPlaceholder: labels.modelSearchPlaceholder,
382
+ onFavoriteModelToggle: setModelFavorite,
307
383
  onModelChange: presenter.chatInputManager.selectModel,
308
384
  onThinkingChange: (value) => {
309
385
  if (value) {
@@ -315,7 +391,8 @@ export function ChatInputBarContainer() {
315
391
  selectedModel: snapshot.selectedModel,
316
392
  selectedThinkingLevel: snapshot.selectedThinkingLevel as ChatThinkingLevel | null,
317
393
  thinkingSupportedLevels,
318
- thinkingDefaultLevel: selectedModelOption?.thinkingCapability?.default ?? null
394
+ thinkingDefaultLevel: selectedModelOption?.thinkingCapability?.default ?? null,
395
+ unfavoriteModelLabel: labels.unfavoriteModelLabel
319
396
  });
320
397
  const skillPicker = buildSkillPicker({
321
398
  allSkillsLabel: labels.allSkillsLabel,
@@ -333,6 +410,7 @@ export function ChatInputBarContainer() {
333
410
  <>
334
411
  <ChatInputBar
335
412
  ref={inputBarRef}
413
+ surface={surface}
336
414
  composer={{
337
415
  nodes: snapshot.composerNodes,
338
416
  placeholder: textareaPlaceholder,
@@ -14,6 +14,7 @@ import { chatRecentModelsManager } from '@/features/chat/managers/chat-recent-mo
14
14
  import type { ChatInputSnapshot } from '@/features/chat/stores/chat-input.store';
15
15
  import { useChatQueryStore } from '@/features/chat/stores/ncp-chat-query.store';
16
16
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
17
+ import { normalizeSessionProjectRootValue } from '@/shared/lib/session-project';
17
18
 
18
19
  const EMPTY_SESSION_SKILL_RECORDS: SessionSkillEntryView[] = [];
19
20
  const EMPTY_NCP_SESSION_SUMMARIES: NcpSessionSummaryView[] = [];
@@ -105,6 +106,7 @@ export function useChatInputBarQueryState(snapshot: ChatInputSnapshot) {
105
106
 
106
107
  return {
107
108
  defaultModel: config?.agents.defaults.model,
109
+ defaultProjectRoot: normalizeSessionProjectRootValue(config?.agents.defaults.workspace),
108
110
  fallbackPreferredModel,
109
111
  fallbackPreferredThinking,
110
112
  isProviderStateResolved,
@@ -0,0 +1,88 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ fetchPreference,
5
+ PREFERENCE_KEYS,
6
+ updatePreference,
7
+ type PreferenceEntryView,
8
+ type PreferenceJsonValue,
9
+ } from '@/shared/lib/api';
10
+
11
+ const CHAT_MODEL_FAVORITES_PREFERENCE_KEY = PREFERENCE_KEYS.chat.modelFavorites;
12
+ const chatModelFavoritesQueryKey = ['preference', CHAT_MODEL_FAVORITES_PREFERENCE_KEY] as const;
13
+
14
+ function normalizeFavoriteModelValues(value: PreferenceJsonValue | null | undefined): string[] {
15
+ if (!Array.isArray(value)) {
16
+ return [];
17
+ }
18
+ const values: string[] = [];
19
+ for (const item of value) {
20
+ if (typeof item !== 'string') {
21
+ continue;
22
+ }
23
+ const normalized = item.trim();
24
+ if (normalized && !values.includes(normalized)) {
25
+ values.push(normalized);
26
+ }
27
+ }
28
+ return values;
29
+ }
30
+
31
+ export function useChatModelFavorites(availableModelValues: string[]) {
32
+ const queryClient = useQueryClient();
33
+ const availableModelValueSet = useMemo(
34
+ () => new Set(availableModelValues),
35
+ [availableModelValues],
36
+ );
37
+ const favoritesQuery = useQuery({
38
+ queryKey: chatModelFavoritesQueryKey,
39
+ queryFn: () => fetchPreference(CHAT_MODEL_FAVORITES_PREFERENCE_KEY),
40
+ staleTime: 30_000,
41
+ });
42
+ const favoriteModelValues = useMemo(
43
+ () => normalizeFavoriteModelValues(favoritesQuery.data?.value),
44
+ [favoritesQuery.data?.value],
45
+ );
46
+ const visibleFavoriteModelValues = useMemo(
47
+ () => favoriteModelValues.filter((value) => availableModelValueSet.has(value)),
48
+ [availableModelValueSet, favoriteModelValues],
49
+ );
50
+ const updateFavorites = useMutation({
51
+ mutationFn: async (values: string[]) =>
52
+ await updatePreference(CHAT_MODEL_FAVORITES_PREFERENCE_KEY, values),
53
+ onMutate: async (values) => {
54
+ await queryClient.cancelQueries({ queryKey: chatModelFavoritesQueryKey });
55
+ const previous = queryClient.getQueryData<PreferenceEntryView>(chatModelFavoritesQueryKey);
56
+ queryClient.setQueryData<PreferenceEntryView>(chatModelFavoritesQueryKey, {
57
+ key: CHAT_MODEL_FAVORITES_PREFERENCE_KEY,
58
+ value: values,
59
+ updatedAt: new Date().toISOString(),
60
+ });
61
+ return { previous };
62
+ },
63
+ onError: (_error, _values, context) => {
64
+ if (context?.previous) {
65
+ queryClient.setQueryData(chatModelFavoritesQueryKey, context.previous);
66
+ }
67
+ },
68
+ onSuccess: (entry) => {
69
+ queryClient.setQueryData(chatModelFavoritesQueryKey, entry);
70
+ },
71
+ });
72
+ const setModelFavorite = useCallback((value: string, favorite: boolean) => {
73
+ const normalized = value.trim();
74
+ if (!normalized) {
75
+ return;
76
+ }
77
+ const next = favorite
78
+ ? [normalized, ...favoriteModelValues.filter((item) => item !== normalized)]
79
+ : favoriteModelValues.filter((item) => item !== normalized);
80
+ updateFavorites.mutate(next);
81
+ }, [favoriteModelValues, updateFavorites]);
82
+
83
+ return {
84
+ favoriteModelValues: visibleFavoriteModelValues,
85
+ isLoading: favoritesQuery.isLoading,
86
+ setModelFavorite,
87
+ };
88
+ }
@@ -13,6 +13,20 @@ function createSkillRecord(partial: Partial<ChatSkillRecord>): ChatSkillRecord {
13
13
  };
14
14
  }
15
15
 
16
+ function createModelTexts() {
17
+ return {
18
+ modelSelectPlaceholder: 'Select model',
19
+ modelNoOptionsLabel: 'No models',
20
+ modelSearchPlaceholder: 'Search models',
21
+ modelSearchEmptyLabel: 'No matching models',
22
+ favoriteModelsLabel: 'Favorites',
23
+ favoriteModelLabel: 'Favorite model',
24
+ unfavoriteModelLabel: 'Remove favorite',
25
+ recentModelsLabel: 'Recent',
26
+ allModelsLabel: 'All models'
27
+ };
28
+ }
29
+
16
30
  describe('buildChatSlashItems', () => {
17
31
  const texts = {
18
32
  slashSkillSubtitle: 'Skill',
@@ -188,12 +202,7 @@ describe('buildModelToolbarSelect', () => {
188
202
  isModelOptionsLoading: false,
189
203
  hasModelOptions: true,
190
204
  onValueChange,
191
- texts: {
192
- modelSelectPlaceholder: 'Select model',
193
- modelNoOptionsLabel: 'No models',
194
- recentModelsLabel: 'Recent',
195
- allModelsLabel: 'All models'
196
- }
205
+ texts: createModelTexts()
197
206
  });
198
207
 
199
208
  expect(select.value).toBe('minimax/MiniMax-M2.7');
@@ -218,12 +227,7 @@ describe('buildModelToolbarSelect', () => {
218
227
  isModelOptionsLoading: false,
219
228
  hasModelOptions: true,
220
229
  onValueChange: vi.fn(),
221
- texts: {
222
- modelSelectPlaceholder: 'Select model',
223
- modelNoOptionsLabel: 'No models',
224
- recentModelsLabel: 'Recent',
225
- allModelsLabel: 'All models'
226
- }
230
+ texts: createModelTexts()
227
231
  });
228
232
 
229
233
  expect(select.selectedLabel).toBe('Anthropic/claude-sonnet-4-very-long-name');
@@ -254,12 +258,7 @@ describe('buildModelToolbarSelect', () => {
254
258
  isModelOptionsLoading: false,
255
259
  hasModelOptions: true,
256
260
  onValueChange: vi.fn(),
257
- texts: {
258
- modelSelectPlaceholder: 'Select model',
259
- modelNoOptionsLabel: 'No models',
260
- recentModelsLabel: 'Recent',
261
- allModelsLabel: 'All models'
262
- }
261
+ texts: createModelTexts()
263
262
  });
264
263
 
265
264
  expect(select.groups).toEqual([
@@ -290,6 +289,52 @@ describe('buildModelToolbarSelect', () => {
290
289
  ]);
291
290
  });
292
291
 
292
+ it('groups favorite models ahead of recent models without duplicates', () => {
293
+ const onFavoriteToggle = vi.fn();
294
+ const select = buildModelToolbarSelect({
295
+ modelOptions: [
296
+ {
297
+ value: 'openai/gpt-5',
298
+ modelLabel: 'gpt-5',
299
+ providerLabel: 'OpenAI'
300
+ },
301
+ {
302
+ value: 'anthropic/claude-sonnet-4',
303
+ modelLabel: 'claude-sonnet-4',
304
+ providerLabel: 'Anthropic'
305
+ },
306
+ {
307
+ value: 'minimax/MiniMax-M2.7',
308
+ modelLabel: 'MiniMax-M2.7',
309
+ providerLabel: 'MiniMax'
310
+ }
311
+ ],
312
+ favoriteModelValues: ['openai/gpt-5'],
313
+ recentModelValues: ['anthropic/claude-sonnet-4', 'openai/gpt-5'],
314
+ selectedModel: 'openai/gpt-5',
315
+ isModelOptionsLoading: false,
316
+ hasModelOptions: true,
317
+ onFavoriteToggle,
318
+ onValueChange: vi.fn(),
319
+ texts: createModelTexts()
320
+ });
321
+
322
+ expect(select.groups?.map((group) => group.key)).toEqual([
323
+ 'favorite-models',
324
+ 'recent-models',
325
+ 'all-models'
326
+ ]);
327
+ expect(select.groups?.[0]?.options.map((option) => option.value)).toEqual(['openai/gpt-5']);
328
+ expect(select.groups?.[1]?.options.map((option) => option.value)).toEqual(['anthropic/claude-sonnet-4']);
329
+ expect(select.groups?.[2]?.options.map((option) => option.value)).toEqual(['minimax/MiniMax-M2.7']);
330
+ expect(select.optionAction).toMatchObject({
331
+ kind: 'favorite',
332
+ activeValues: ['openai/gpt-5'],
333
+ activeLabel: 'Remove favorite',
334
+ inactiveLabel: 'Favorite model'
335
+ });
336
+ });
337
+
293
338
  it('preserves recent model order from newest to oldest', () => {
294
339
  const select = buildModelToolbarSelect({
295
340
  modelOptions: [
@@ -314,12 +359,7 @@ describe('buildModelToolbarSelect', () => {
314
359
  isModelOptionsLoading: false,
315
360
  hasModelOptions: true,
316
361
  onValueChange: vi.fn(),
317
- texts: {
318
- modelSelectPlaceholder: 'Select model',
319
- modelNoOptionsLabel: 'No models',
320
- recentModelsLabel: 'Recent',
321
- allModelsLabel: 'All models'
322
- }
362
+ texts: createModelTexts()
323
363
  });
324
364
 
325
365
  expect(select.groups?.[0]?.options.map((option) => option.value)).toEqual([
@@ -19,6 +19,7 @@ function createSnapshot(
19
19
  pendingSessionType: 'native',
20
20
  pendingProjectRoot: null,
21
21
  pendingProjectRootSessionKey: null,
22
+ defaultProjectRoot: null,
22
23
  defaultSessionType: 'native',
23
24
  canStopGeneration: false,
24
25
  stopDisabledReason: null,
@@ -208,8 +208,17 @@ export function buildSkillPickerModel(params: {
208
208
  allSkillsLabel: string;
209
209
  };
210
210
  }): ChatSkillPickerProps {
211
- const prioritizedSkillRecords = prioritizeSkillRecords(params.skillRecords, params.recentSkillValues ?? []);
212
- const recentKeySet = new Set(params.groupedRecentSkillValues ?? []);
211
+ const {
212
+ groupedRecentSkillValues,
213
+ isLoading,
214
+ onSelectedKeysChange,
215
+ recentSkillValues,
216
+ selectedSkills,
217
+ skillRecords,
218
+ texts,
219
+ } = params;
220
+ const prioritizedSkillRecords = prioritizeSkillRecords(skillRecords, recentSkillValues ?? []);
221
+ const recentKeySet = new Set(groupedRecentSkillValues ?? []);
213
222
  const recentSkillOptions = buildSkillPickerOptions(
214
223
  prioritizedSkillRecords.filter((record) => recentKeySet.has(record.key))
215
224
  );
@@ -217,12 +226,12 @@ export function buildSkillPickerModel(params: {
217
226
  prioritizedSkillRecords.filter((record) => !recentKeySet.has(record.key))
218
227
  );
219
228
  return {
220
- title: params.texts.title,
221
- searchPlaceholder: params.texts.searchPlaceholder,
222
- emptyLabel: params.texts.emptyLabel,
223
- loadingLabel: params.texts.loadingLabel,
224
- isLoading: params.isLoading,
225
- manageLabel: params.texts.manageLabel,
229
+ title: texts.title,
230
+ searchPlaceholder: texts.searchPlaceholder,
231
+ emptyLabel: texts.emptyLabel,
232
+ loadingLabel: texts.loadingLabel,
233
+ isLoading,
234
+ manageLabel: texts.manageLabel,
226
235
  manageHref: '/marketplace/skills',
227
236
  options: buildSkillPickerOptions(prioritizedSkillRecords),
228
237
  groups:
@@ -230,17 +239,17 @@ export function buildSkillPickerModel(params: {
230
239
  ? [
231
240
  {
232
241
  key: 'recent-skills',
233
- label: params.texts.recentSkillsLabel,
242
+ label: texts.recentSkillsLabel,
234
243
  options: recentSkillOptions
235
244
  },
236
245
  {
237
246
  key: 'all-skills',
238
- label: params.texts.allSkillsLabel,
247
+ label: texts.allSkillsLabel,
239
248
  options: remainingSkillOptions
240
249
  }
241
250
  ].filter((group) => group.options.length > 0)
242
251
  : undefined,
243
- selectedKeys: params.selectedSkills,
244
- onSelectedKeysChange: params.onSelectedKeysChange
252
+ selectedKeys: selectedSkills,
253
+ onSelectedKeysChange
245
254
  };
246
255
  }
@@ -35,10 +35,16 @@ export function buildModelStateHint(params: {
35
35
  "noModelOptionsLabel" | "configureProviderLabel"
36
36
  >;
37
37
  }): ChatInlineHint | null {
38
- if (!params.isModelOptionsLoading && !params.isModelOptionsEmpty) {
38
+ const {
39
+ isModelOptionsEmpty,
40
+ isModelOptionsLoading,
41
+ onGoToProviders,
42
+ texts,
43
+ } = params;
44
+ if (!isModelOptionsLoading && !isModelOptionsEmpty) {
39
45
  return null;
40
46
  }
41
- if (params.isModelOptionsLoading) {
47
+ if (isModelOptionsLoading) {
42
48
  return {
43
49
  tone: "neutral",
44
50
  loading: true,
@@ -46,25 +52,40 @@ export function buildModelStateHint(params: {
46
52
  }
47
53
  return {
48
54
  tone: "warning",
49
- text: params.texts.noModelOptionsLabel,
50
- actionLabel: params.texts.configureProviderLabel,
51
- onAction: params.onGoToProviders,
55
+ text: texts.noModelOptionsLabel,
56
+ actionLabel: texts.configureProviderLabel,
57
+ onAction: onGoToProviders,
52
58
  };
53
59
  }
54
60
 
55
61
  export function buildModelToolbarSelect({
56
- modelOptions, recentModelValues, selectedModel, isModelOptionsLoading, hasModelOptions, onValueChange, texts,
62
+ modelOptions,
63
+ favoriteModelValues,
64
+ recentModelValues,
65
+ selectedModel,
66
+ isModelOptionsLoading,
67
+ hasModelOptions,
68
+ onFavoriteToggle,
69
+ onValueChange,
70
+ texts,
57
71
  }: {
58
72
  modelOptions: ChatModelRecord[];
73
+ favoriteModelValues?: string[];
59
74
  recentModelValues?: string[];
60
75
  selectedModel: string;
61
76
  isModelOptionsLoading: boolean;
62
77
  hasModelOptions: boolean;
78
+ onFavoriteToggle?: (value: string, favorite: boolean) => void;
63
79
  onValueChange: (value: string) => void;
64
80
  texts: Pick<
65
81
  ChatInputBarAdapterTexts,
66
82
  | "modelSelectPlaceholder"
67
83
  | "modelNoOptionsLabel"
84
+ | "modelSearchPlaceholder"
85
+ | "modelSearchEmptyLabel"
86
+ | "favoriteModelsLabel"
87
+ | "favoriteModelLabel"
88
+ | "unfavoriteModelLabel"
68
89
  | "recentModelsLabel"
69
90
  | "allModelsLabel"
70
91
  >;
@@ -76,19 +97,33 @@ export function buildModelToolbarSelect({
76
97
  const resolvedValue = hasModelOptions
77
98
  ? resolvedModelOption?.value
78
99
  : undefined;
79
- const recentValueSet = new Set(recentModelValues ?? []);
80
100
  const modelOptionMap = new Map(
81
101
  modelOptions.map((option) => [option.value, option] as const),
82
102
  );
83
- const recentOptions = (recentModelValues ?? [])
103
+ const favoriteOptions = (favoriteModelValues ?? [])
84
104
  .map((value) => modelOptionMap.get(value))
85
105
  .filter((option): option is ChatModelRecord => Boolean(option));
106
+ const favoriteValueSet = new Set(favoriteOptions.map((option) => option.value));
107
+ const recentOptions = (recentModelValues ?? [])
108
+ .map((value) => modelOptionMap.get(value))
109
+ .filter(
110
+ (option): option is ChatModelRecord =>
111
+ option !== undefined && !favoriteValueSet.has(option.value),
112
+ );
113
+ const recentValueSet = new Set(recentOptions.map((option) => option.value));
86
114
  const remainingOptions = modelOptions.filter(
87
- (option) => !recentValueSet.has(option.value),
115
+ (option) => !favoriteValueSet.has(option.value) && !recentValueSet.has(option.value),
88
116
  );
89
- const optionGroups =
90
- recentOptions.length > 0
91
- ? [
117
+ const optionGroups = favoriteOptions.length > 0 || recentOptions.length > 0
118
+ ? [
119
+ {
120
+ key: "favorite-models",
121
+ label: texts.favoriteModelsLabel,
122
+ options: favoriteOptions.map((option) => ({
123
+ value: option.value,
124
+ label: formatModelOptionLabel(option),
125
+ })),
126
+ },
92
127
  {
93
128
  key: "recent-models",
94
129
  label: texts.recentModelsLabel,
@@ -106,7 +141,7 @@ export function buildModelToolbarSelect({
106
141
  })),
107
142
  },
108
143
  ].filter((group) => group.options.length > 0)
109
- : undefined;
144
+ : undefined;
110
145
 
111
146
  return {
112
147
  key: "model",
@@ -124,6 +159,19 @@ export function buildModelToolbarSelect({
124
159
  disabled: !hasModelOptions,
125
160
  loading: isModelOptionsLoading,
126
161
  emptyLabel: texts.modelNoOptionsLabel,
162
+ search: {
163
+ placeholder: texts.modelSearchPlaceholder,
164
+ emptyLabel: texts.modelSearchEmptyLabel,
165
+ },
166
+ optionAction: onFavoriteToggle
167
+ ? {
168
+ kind: "favorite",
169
+ activeValues: favoriteOptions.map((option) => option.value),
170
+ activeLabel: texts.unfavoriteModelLabel,
171
+ inactiveLabel: texts.favoriteModelLabel,
172
+ onToggle: onFavoriteToggle,
173
+ }
174
+ : undefined,
127
175
  onValueChange,
128
176
  };
129
177
  }
@@ -135,31 +183,38 @@ export function buildThinkingToolbarSelect(params: {
135
183
  onValueChange: (value: ChatThinkingLevel) => void;
136
184
  texts: Pick<ChatInputBarAdapterTexts, "thinkingLabels">;
137
185
  }): ChatToolbarSelect | null {
138
- if (params.supportedLevels.length === 0) {
186
+ const {
187
+ defaultThinkingLevel,
188
+ onValueChange,
189
+ selectedThinkingLevel,
190
+ supportedLevels,
191
+ texts,
192
+ } = params;
193
+ if (supportedLevels.length === 0) {
139
194
  return null;
140
195
  }
141
196
 
142
- const options = normalizeThinkingLevels(params.supportedLevels);
197
+ const options = normalizeThinkingLevels(supportedLevels);
143
198
  const fallback = options.includes("off") ? "off" : options[0];
144
199
  const resolvedValue =
145
- (params.selectedThinkingLevel &&
146
- options.includes(params.selectedThinkingLevel) &&
147
- params.selectedThinkingLevel) ||
148
- (params.defaultThinkingLevel &&
149
- options.includes(params.defaultThinkingLevel) &&
150
- params.defaultThinkingLevel) ||
200
+ (selectedThinkingLevel &&
201
+ options.includes(selectedThinkingLevel) &&
202
+ selectedThinkingLevel) ||
203
+ (defaultThinkingLevel &&
204
+ options.includes(defaultThinkingLevel) &&
205
+ defaultThinkingLevel) ||
151
206
  fallback;
152
207
 
153
208
  return {
154
209
  key: "thinking",
155
210
  value: resolvedValue,
156
- placeholder: params.texts.thinkingLabels[resolvedValue],
157
- selectedLabel: params.texts.thinkingLabels[resolvedValue],
211
+ placeholder: texts.thinkingLabels[resolvedValue],
212
+ selectedLabel: texts.thinkingLabels[resolvedValue],
158
213
  icon: "brain",
159
214
  options: options.map((level) => ({
160
215
  value: level,
161
- label: params.texts.thinkingLabels[level],
216
+ label: texts.thinkingLabels[level],
162
217
  })),
163
- onValueChange: (value) => params.onValueChange(value as ChatThinkingLevel),
218
+ onValueChange: (value) => onValueChange(value as ChatThinkingLevel),
164
219
  };
165
220
  }