@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
@@ -7,9 +7,9 @@ type ProviderStatusBadgeProps = {
7
7
  className?: string;
8
8
  };
9
9
 
10
- export function ProviderStatusBadge(props: ProviderStatusBadgeProps) {
11
- if (!props.enabled) {
12
- return <StatusDot status="inactive" label={t('disabled')} className={props.className} />;
10
+ export function ProviderStatusBadge({ apiKeySet, className, enabled }: ProviderStatusBadgeProps) {
11
+ if (!enabled) {
12
+ return <StatusDot status="inactive" label={t('disabled')} className={className} />;
13
13
  }
14
- return <StatusDot status={props.apiKeySet ? 'ready' : 'setup'} label={props.apiKeySet ? t('statusReady') : t('statusSetup')} className={props.className} />;
14
+ return <StatusDot status={apiKeySet ? 'ready' : 'setup'} label={apiKeySet ? t('statusReady') : t('statusSetup')} className={className} />;
15
15
  }
@@ -7,114 +7,18 @@ import { Label } from '@/shared/components/ui/label';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
8
8
  import { Switch } from '@/shared/components/ui/switch';
9
9
  import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
10
- import type { SecretProviderView, SecretRefView, SecretSourceView, SecretsView } from '@/shared/lib/api';
10
+ import type { SecretSourceView } from '@/shared/lib/api';
11
11
  import { t } from '@/shared/lib/i18n';
12
12
  import { toast } from 'sonner';
13
-
14
- type ProviderRow = {
15
- alias: string;
16
- source: SecretSourceView;
17
- prefix: string;
18
- path: string;
19
- command: string;
20
- argsText: string;
21
- cwd: string;
22
- timeoutMs: number;
23
- };
24
-
25
- type RefRow = {
26
- path: string;
27
- source: SecretSourceView;
28
- provider: string;
29
- id: string;
30
- };
31
-
32
- export type SecretsFormState = {
33
- enabled: boolean;
34
- defaultEnv: string;
35
- defaultFile: string;
36
- defaultExec: string;
37
- providers: ProviderRow[];
38
- refs: RefRow[];
39
- };
40
-
41
- const SOURCE_OPTIONS: SecretSourceView[] = ['env', 'file', 'exec'];
42
-
43
- function createProviderRow(alias = ''): ProviderRow {
44
- return { alias, source: 'env', prefix: '', path: '', command: '', argsText: '', cwd: '', timeoutMs: 5000 };
45
- }
46
-
47
- function createRefRow(): RefRow {
48
- return { path: '', source: 'env', provider: '', id: '' };
49
- }
50
-
51
- function providerToRow(alias: string, provider: SecretProviderView): ProviderRow {
52
- if (provider.source === 'env') return { ...createProviderRow(alias), source: 'env', prefix: provider.prefix ?? '' };
53
- if (provider.source === 'file') return { ...createProviderRow(alias), source: 'file', path: provider.path };
54
- return {
55
- ...createProviderRow(alias),
56
- source: 'exec',
57
- command: provider.command,
58
- argsText: (provider.args ?? []).join('\n'),
59
- cwd: provider.cwd ?? '',
60
- timeoutMs: provider.timeoutMs ?? 5000,
61
- };
62
- }
63
-
64
- function rowToProvider(row: ProviderRow): SecretProviderView {
65
- if (row.source === 'env') return { source: 'env', ...(row.prefix.trim() ? { prefix: row.prefix.trim() } : {}) };
66
- if (row.source === 'file') return { source: 'file', path: row.path.trim(), format: 'json' };
67
- return {
68
- source: 'exec',
69
- command: row.command.trim(),
70
- args: row.argsText.split('\n').map((item) => item.trim()).filter(Boolean),
71
- ...(row.cwd.trim() ? { cwd: row.cwd.trim() } : {}),
72
- timeoutMs: Math.max(1, Math.trunc(row.timeoutMs || 5000)),
73
- };
74
- }
75
-
76
- export function buildSecretsFormState(secrets?: SecretsView): SecretsFormState {
77
- if (!secrets) {
78
- return { enabled: true, defaultEnv: '', defaultFile: '', defaultExec: '', providers: [], refs: [] };
79
- }
80
- return {
81
- enabled: Boolean(secrets.enabled),
82
- defaultEnv: secrets.defaults.env ?? '',
83
- defaultFile: secrets.defaults.file ?? '',
84
- defaultExec: secrets.defaults.exec ?? '',
85
- providers: Object.entries(secrets.providers).map(([alias, provider]) => providerToRow(alias, provider)),
86
- refs: Object.entries(secrets.refs).map(([path, ref]) => ({
87
- path,
88
- source: ref.source,
89
- provider: ref.provider ?? '',
90
- id: ref.id,
91
- })),
92
- };
93
- }
94
-
95
- export function buildSecretsSubmitPayload(state: SecretsFormState) {
96
- return {
97
- enabled: state.enabled,
98
- defaults: {
99
- env: state.defaultEnv || null,
100
- file: state.defaultFile || null,
101
- exec: state.defaultExec || null,
102
- },
103
- providers: Object.fromEntries(
104
- state.providers.map((row) => [row.alias.trim(), rowToProvider(row)]).filter(([alias]) => alias),
105
- ),
106
- refs: Object.fromEntries(
107
- state.refs.map((row) => [
108
- row.path.trim(),
109
- {
110
- source: row.source,
111
- ...(row.provider.trim() ? { provider: row.provider.trim() } : {}),
112
- id: row.id.trim(),
113
- },
114
- ]).filter(([path, ref]) => path && (ref as SecretRefView).id),
115
- ),
116
- };
117
- }
13
+ import {
14
+ SOURCE_OPTIONS,
15
+ createProviderRow,
16
+ createRefRow,
17
+ prepareSecretsFormSubmitState,
18
+ type ProviderRow,
19
+ type RefRow,
20
+ type SecretsFormState,
21
+ } from '@/features/settings/utils/secrets-config-form.utils';
118
22
 
119
23
  function SecretsDefaultsCard(props: {
120
24
  defaultEnv: string;
@@ -324,35 +228,7 @@ export function SecretsConfigForm(props: {
324
228
 
325
229
  const handleSave = () => {
326
230
  try {
327
- const providerMap: Record<string, SecretProviderView> = {};
328
- for (const [index, row] of state.providers.entries()) {
329
- const alias = row.alias.trim();
330
- if (!alias) throw new Error(`${t('providerAlias')} #${index + 1} ${t('isRequired')}`);
331
- if (providerMap[alias]) throw new Error(`${t('providerAlias')}: ${alias} (${t('duplicate')})`);
332
- if (row.source === 'file' && !row.path.trim()) throw new Error(`${t('secretFilePath')} #${index + 1} ${t('isRequired')}`);
333
- if (row.source === 'exec' && !row.command.trim()) throw new Error(`${t('secretExecCommand')} #${index + 1} ${t('isRequired')}`);
334
- providerMap[alias] = rowToProvider(row);
335
- }
336
-
337
- const refMap: Record<string, SecretRefView> = {};
338
- for (const [index, row] of state.refs.entries()) {
339
- const path = row.path.trim();
340
- const id = row.id.trim();
341
- if (!path) throw new Error(`${t('secretConfigPath')} #${index + 1} ${t('isRequired')}`);
342
- if (!id) throw new Error(`${t('secretId')} #${index + 1} ${t('isRequired')}`);
343
- const provider = row.provider.trim();
344
- if (provider && !providerMap[provider]) throw new Error(`${t('secretProviderAlias')}: ${provider} ${t('notFound')}`);
345
- refMap[path] = { source: row.source, ...(provider ? { provider } : {}), id };
346
- }
347
-
348
- onSubmit({
349
- enabled: state.enabled,
350
- defaultEnv: state.defaultEnv.trim(),
351
- defaultFile: state.defaultFile.trim(),
352
- defaultExec: state.defaultExec.trim(),
353
- providers: Object.entries(providerMap).map(([alias, provider]) => providerToRow(alias, provider)),
354
- refs: Object.entries(refMap).map(([path, ref]) => ({ path, source: ref.source, provider: ref.provider ?? '', id: ref.id })),
355
- });
231
+ onSubmit(prepareSecretsFormSubmitState(state));
356
232
  } catch (error) {
357
233
  toast.error(error instanceof Error ? error.message : String(error));
358
234
  }
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { toast } from 'sonner';
4
+ import type { ProviderTemplateView } from '@/shared/lib/api';
5
+ import {
6
+ useImportProviderAuthFromCli,
7
+ usePollProviderAuth,
8
+ useStartProviderAuth
9
+ } from '@/shared/hooks/use-config';
10
+ import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
11
+ import { t } from '@/shared/lib/i18n';
12
+
13
+ type UseProviderAuthFlowParams = {
14
+ providerName?: string;
15
+ providerAuth?: ProviderTemplateView['auth'];
16
+ resolvedAuthMethodId: string;
17
+ };
18
+
19
+ export function useProviderAuthFlow(params: UseProviderAuthFlowParams) {
20
+ const { providerName, providerAuth, resolvedAuthMethodId } = params;
21
+ const queryClient = useQueryClient();
22
+ const startProviderAuth = useStartProviderAuth();
23
+ const pollProviderAuth = usePollProviderAuth();
24
+ const importProviderAuthFromCli = useImportProviderAuthFromCli();
25
+ const [authProviderName, setAuthProviderName] = useState('');
26
+ const [authSessionId, setAuthSessionId] = useState<string | null>(null);
27
+ const [authStatusMessage, setAuthStatusMessage] = useState('');
28
+ const authPollTimerRef = useRef<number | null>(null);
29
+
30
+ const clearAuthPollTimer = useCallback(() => {
31
+ if (authPollTimerRef.current !== null) {
32
+ window.clearTimeout(authPollTimerRef.current);
33
+ authPollTimerRef.current = null;
34
+ }
35
+ }, []);
36
+
37
+ const scheduleProviderAuthPoll = useCallback(
38
+ (sessionId: string, delayMs: number) => {
39
+ clearAuthPollTimer();
40
+ authPollTimerRef.current = window.setTimeout(() => {
41
+ void (async () => {
42
+ if (!providerName) {
43
+ return;
44
+ }
45
+ try {
46
+ const result = await pollProviderAuth.mutateAsync({ provider: providerName, data: { sessionId } });
47
+ if (result.status === 'pending') {
48
+ setAuthStatusMessage(t('providerAuthWaitingBrowser'));
49
+ scheduleProviderAuthPoll(sessionId, result.nextPollMs ?? delayMs);
50
+ return;
51
+ }
52
+ if (result.status === 'authorized') {
53
+ setAuthSessionId(null);
54
+ clearAuthPollTimer();
55
+ setAuthStatusMessage(t('providerAuthCompleted'));
56
+ toast.success(t('providerAuthCompleted'));
57
+ queryClient.invalidateQueries({ queryKey: ['config'] });
58
+ queryClient.invalidateQueries({ queryKey: ['providers'] });
59
+ return;
60
+ }
61
+ setAuthSessionId(null);
62
+ clearAuthPollTimer();
63
+ setAuthStatusMessage(result.message || `Authorization ${result.status}.`);
64
+ toast.error(result.message || `Authorization ${result.status}.`);
65
+ } catch (error) {
66
+ setAuthSessionId(null);
67
+ clearAuthPollTimer();
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ setAuthStatusMessage(message);
70
+ toast.error(`Authorization failed: ${message}`);
71
+ }
72
+ })();
73
+ }, Math.max(1000, delayMs));
74
+ },
75
+ [clearAuthPollTimer, pollProviderAuth, providerName, queryClient]
76
+ );
77
+
78
+ const startAuth = useCallback(async () => {
79
+ if (!providerName || providerAuth?.kind !== 'device_code') {
80
+ return;
81
+ }
82
+
83
+ try {
84
+ setAuthProviderName(providerName);
85
+ setAuthStatusMessage('');
86
+ const result = await startProviderAuth.mutateAsync({
87
+ provider: providerName,
88
+ data: resolvedAuthMethodId ? { methodId: resolvedAuthMethodId } : {}
89
+ });
90
+ if (!result.sessionId || !result.verificationUri) {
91
+ throw new Error(t('providerAuthStartFailed'));
92
+ }
93
+ setAuthSessionId(result.sessionId);
94
+ setAuthStatusMessage(`${t('providerAuthOpenPrompt')}${result.userCode}${t('providerAuthOpenPromptSuffix')}`);
95
+ await hostCapabilityManager.openExternalUrl(result.verificationUri);
96
+ scheduleProviderAuthPoll(result.sessionId, result.intervalMs);
97
+ } catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ setAuthSessionId(null);
100
+ clearAuthPollTimer();
101
+ setAuthStatusMessage(message);
102
+ toast.error(`${t('providerAuthStartFailed')}: ${message}`);
103
+ }
104
+ }, [clearAuthPollTimer, providerAuth?.kind, providerName, resolvedAuthMethodId, scheduleProviderAuthPoll, startProviderAuth]);
105
+
106
+ const importAuthFromCli = useCallback(async () => {
107
+ if (!providerName || providerAuth?.kind !== 'device_code') {
108
+ return;
109
+ }
110
+ try {
111
+ setAuthProviderName(providerName);
112
+ clearAuthPollTimer();
113
+ setAuthSessionId(null);
114
+ const result = await importProviderAuthFromCli.mutateAsync({ provider: providerName });
115
+ setAuthStatusMessage(`${t('providerAuthImportStatusPrefix')}${result.expiresAt ? ` (expires: ${result.expiresAt})` : ''}`);
116
+ toast.success(t('providerAuthImportSuccess'));
117
+ queryClient.invalidateQueries({ queryKey: ['config'] });
118
+ queryClient.invalidateQueries({ queryKey: ['providers'] });
119
+ } catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ setAuthStatusMessage(message);
122
+ toast.error(`${t('providerAuthImportFailed')}: ${message}`);
123
+ }
124
+ }, [clearAuthPollTimer, importProviderAuthFromCli, providerAuth?.kind, providerName, queryClient]);
125
+
126
+ useEffect(() => {
127
+ clearAuthPollTimer();
128
+ }, [clearAuthPollTimer, providerName]);
129
+
130
+ useEffect(() => () => clearAuthPollTimer(), [clearAuthPollTimer]);
131
+
132
+ return {
133
+ authSessionId: authProviderName === providerName ? authSessionId : null,
134
+ authStatusMessage: authProviderName === providerName ? authStatusMessage : '',
135
+ importAuthFromCli,
136
+ importPending: importProviderAuthFromCli.isPending,
137
+ startAuth,
138
+ startPending: startProviderAuth.isPending
139
+ };
140
+ }
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
3
3
  import { useState } from 'react';
4
4
  import { ProviderScopedModelInput } from '@/shared/components/common/provider-scoped-model-input';
5
5
  import { ModelConfigPage } from '@/features/settings/pages/model-config-page';
6
+ import { createPopoverAvailableHeightLimit } from '@/shared/components/ui/popover';
6
7
  import { setLanguage } from '@/shared/lib/i18n';
7
8
 
8
9
  const mocks = vi.hoisted(() => ({
@@ -279,4 +280,19 @@ describe('ModelConfigPage', () => {
279
280
  expect(providerTrigger.textContent).toContain('CustomHub');
280
281
  expect(screen.getByDisplayValue('reasoner-v1')).toBeTruthy();
281
282
  });
283
+
284
+ it('uses the shared popover height contract for searchable model options', async () => {
285
+ const user = userEvent.setup();
286
+
287
+ render(<ModelConfigPage />);
288
+
289
+ await user.click(screen.getByRole('button', { name: 'Toggle model options' }));
290
+
291
+ const option = await screen.findByText('gpt-5.2');
292
+ const panel = option.closest('[data-state="open"]') as HTMLElement | null;
293
+ expect(panel?.style.maxHeight).toBe(createPopoverAvailableHeightLimit('15rem'));
294
+ expect(panel?.style.maxHeight).toContain('max(0px');
295
+ expect(panel?.style.maxHeight).toContain('100vh');
296
+ expect(panel?.className).toContain('overflow-hidden');
297
+ });
282
298
  });
@@ -1,6 +1,7 @@
1
1
  import { useConfig, useUpdateSecrets } from '@/shared/hooks/use-config';
2
2
  import { t } from '@/shared/lib/i18n';
3
- import { buildSecretsFormState, buildSecretsSubmitPayload, SecretsConfigForm } from '@/features/settings/components/config/secrets-config-form';
3
+ import { SecretsConfigForm } from '@/features/settings/components/config/secrets-config-form';
4
+ import { buildSecretsFormState, buildSecretsSubmitPayload } from '@/features/settings/utils/secrets-config-form.utils';
4
5
 
5
6
  export function SecretsConfigPage() {
6
7
  const { data: config, isLoading } = useConfig();
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { resolveEditableModels, serializeModelsForSave } from '../provider-form-support.utils';
2
+ import {
3
+ buildProviderSavePayload,
4
+ resolveEditableModels,
5
+ serializeModelsForSave
6
+ } from '@/features/settings/utils/provider-form-support.utils';
7
+ import {
8
+ addProviderLocalModel,
9
+ setModelThinkingDefaultInConfig,
10
+ toggleModelThinkingLevelInConfig
11
+ } from '@/features/settings/utils/provider-form-model.utils';
3
12
 
4
13
  describe('provider form model defaults', () => {
5
14
  it('keeps persisted empty models empty', () => {
@@ -18,4 +27,54 @@ describe('provider form model defaults', () => {
18
27
  'openai-work/gpt-5.4'
19
28
  ]);
20
29
  });
30
+
31
+ it('builds a minimal save payload from changed provider fields', () => {
32
+ expect(buildProviderSavePayload({
33
+ providerName: 'openai-work',
34
+ apiKey: ' sk-test ',
35
+ apiBase: 'https://api.openai.com/v1',
36
+ currentApiBase: 'https://old.example.com',
37
+ defaultApiBase: 'https://api.openai.com/v1',
38
+ extraHeaders: { ' X-Team ': 'nextclaw' },
39
+ currentHeaders: null,
40
+ supportsWireApi: true,
41
+ wireApi: 'responses',
42
+ currentWireApi: 'auto',
43
+ models: ['gpt-5.5'],
44
+ currentEditableModels: [],
45
+ modelConfig: { 'gpt-5.5': { thinking: { supported: ['high'], default: 'high' } } },
46
+ currentModelConfig: {},
47
+ providerDisplayName: ' OpenAI Work ',
48
+ effectiveDisplayName: 'OpenAI'
49
+ })).toEqual({
50
+ apiKey: 'sk-test',
51
+ apiBase: null,
52
+ displayName: 'OpenAI Work',
53
+ extraHeaders: { 'X-Team': 'nextclaw' },
54
+ wireApi: 'responses',
55
+ models: ['openai-work/gpt-5.5'],
56
+ modelConfig: { 'gpt-5.5': { thinking: { supported: ['high'], default: 'high' } } }
57
+ });
58
+ });
59
+
60
+ it('adds local model drafts without accepting provider prefixes', () => {
61
+ expect(addProviderLocalModel([], ' gpt-5.5 ', ['openai-work'])).toEqual({
62
+ models: ['gpt-5.5'],
63
+ draft: ''
64
+ });
65
+ expect(addProviderLocalModel([], ' other/gpt-5.5 ', ['openai-work'])).toEqual({
66
+ models: [],
67
+ draft: ' other/gpt-5.5 ',
68
+ errorKey: 'providerModelInvalidProviderPrefix'
69
+ });
70
+ });
71
+
72
+ it('keeps model thinking defaults inside the supported level set', () => {
73
+ const config = toggleModelThinkingLevelInConfig({}, 'gpt-5.5', 'high');
74
+
75
+ expect(setModelThinkingDefaultInConfig(config, 'gpt-5.5', 'high')).toEqual({
76
+ 'gpt-5.5': { thinking: { supported: ['high'], default: 'high' } }
77
+ });
78
+ expect(setModelThinkingDefaultInConfig(config, 'gpt-5.5', 'low')).toBe(config);
79
+ });
21
80
  });
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildSecretsFormState,
4
+ buildSecretsSubmitPayload,
5
+ createProviderRow,
6
+ createRefRow,
7
+ prepareSecretsFormSubmitState,
8
+ type SecretsFormState,
9
+ } from '@/features/settings/utils/secrets-config-form.utils';
10
+
11
+ function createState(patch: Partial<SecretsFormState> = {}): SecretsFormState {
12
+ return {
13
+ enabled: true,
14
+ defaultEnv: '',
15
+ defaultFile: '',
16
+ defaultExec: '',
17
+ providers: [],
18
+ refs: [],
19
+ ...patch,
20
+ };
21
+ }
22
+
23
+ describe('secrets config form utils', () => {
24
+ it('round-trips loaded secrets config into a submit payload', () => {
25
+ const state = buildSecretsFormState({
26
+ enabled: true,
27
+ defaults: { env: 'vault-env', file: 'vault-file' },
28
+ providers: {
29
+ 'vault-env': { source: 'env', prefix: 'NEXTCLAW_' },
30
+ 'vault-file': { source: 'file', path: '/tmp/secrets.json', format: 'json' },
31
+ },
32
+ refs: {
33
+ 'agents.defaults.model': { source: 'env', provider: 'vault-env', id: 'DEFAULT_MODEL' },
34
+ },
35
+ });
36
+
37
+ expect(buildSecretsSubmitPayload(prepareSecretsFormSubmitState(state))).toEqual({
38
+ enabled: true,
39
+ defaults: { env: 'vault-env', file: 'vault-file', exec: null },
40
+ providers: {
41
+ 'vault-env': { source: 'env', prefix: 'NEXTCLAW_' },
42
+ 'vault-file': { source: 'file', path: '/tmp/secrets.json', format: 'json' },
43
+ },
44
+ refs: {
45
+ 'agents.defaults.model': { source: 'env', provider: 'vault-env', id: 'DEFAULT_MODEL' },
46
+ },
47
+ });
48
+ });
49
+
50
+ it('rejects duplicate provider aliases before submit', () => {
51
+ const state = createState({
52
+ providers: [createProviderRow('vault'), createProviderRow(' vault ')],
53
+ });
54
+
55
+ expect(() => prepareSecretsFormSubmitState(state)).toThrow('vault');
56
+ });
57
+
58
+ it('rejects refs that point at an unknown provider', () => {
59
+ const state = createState({
60
+ providers: [createProviderRow('vault-env')],
61
+ refs: [{ ...createRefRow(), path: 'agents.defaults.model', id: 'DEFAULT_MODEL', provider: 'missing' }],
62
+ });
63
+
64
+ expect(() => prepareSecretsFormSubmitState(state)).toThrow('missing');
65
+ });
66
+ });
@@ -0,0 +1,131 @@
1
+ import { hintForPath } from '@/shared/lib/config-hints';
2
+ import { t } from '@/shared/lib/i18n';
3
+ import type { getLanguage } from '@/shared/lib/i18n';
4
+ import type {
5
+ ConfigSchemaResponse,
6
+ ProviderTemplateView,
7
+ ProviderTemplatesView,
8
+ ProvidersView
9
+ } from '@/shared/lib/api';
10
+ import {
11
+ EMPTY_PROVIDER_CONFIG,
12
+ mergeModelConfig,
13
+ normalizeHeaders,
14
+ normalizeModelConfigForModels,
15
+ normalizeModelList,
16
+ resolveEditableModels,
17
+ resolvePreferredAuthMethodId,
18
+ shouldUsePillSelector,
19
+ toProviderLocalModelId,
20
+ type WireApiType
21
+ } from './provider-form-support.utils';
22
+
23
+ type ProviderFormContextInput = {
24
+ providerName?: string;
25
+ providersView?: ProvidersView;
26
+ templatesView?: ProviderTemplatesView;
27
+ schema?: ConfigSchemaResponse;
28
+ language: ReturnType<typeof getLanguage>;
29
+ };
30
+ type ProviderFormAuthContextInput = {
31
+ providerName?: string;
32
+ providerSpec?: ProviderTemplateView;
33
+ language: ReturnType<typeof getLanguage>;
34
+ };
35
+
36
+ function resolveProviderFormAuthContext(input: ProviderFormAuthContextInput) {
37
+ const { providerName, providerSpec, language } = input;
38
+ const providerAuth = providerSpec?.auth;
39
+ const providerAuthMethods = providerAuth?.methods ?? [];
40
+ const providerAuthMethodOptions = providerAuthMethods.map((method) => ({
41
+ value: method.id,
42
+ label: method.label?.[language] || method.label?.en || method.id
43
+ }));
44
+ const preferredAuthMethodId = resolvePreferredAuthMethodId({
45
+ providerName: providerSpec?.providerType ?? providerName,
46
+ methods: providerAuthMethods,
47
+ defaultMethodId: providerAuth?.defaultMethodId,
48
+ language
49
+ });
50
+ const shouldUseAuthMethodPills = shouldUsePillSelector({
51
+ required: providerAuth?.kind === 'device_code',
52
+ hasDefault: Boolean(providerAuth?.defaultMethodId?.trim()),
53
+ optionCount: providerAuthMethods.length
54
+ });
55
+
56
+ return { providerAuth, providerAuthMethodOptions, providerAuthMethods, preferredAuthMethodId, shouldUseAuthMethodPills };
57
+ }
58
+
59
+ function resolveProviderWireApiContext(providerSpec: ProviderTemplateView | undefined, isCustomProvider: boolean) {
60
+ const supportsWireApi = Boolean(providerSpec?.supportsWireApi) || isCustomProvider;
61
+ const wireApiSelectOptions = (providerSpec?.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => ({
62
+ value: option,
63
+ label: option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')
64
+ }));
65
+ const shouldUseWireApiPills = shouldUsePillSelector({
66
+ required: supportsWireApi,
67
+ hasDefault: typeof providerSpec?.defaultWireApi === 'string' && providerSpec.defaultWireApi.length > 0,
68
+ optionCount: wireApiSelectOptions.length
69
+ });
70
+
71
+ return { shouldUseWireApiPills, supportsWireApi, wireApiSelectOptions };
72
+ }
73
+
74
+ export function resolveProviderFormContext(input: ProviderFormContextInput) {
75
+ const { providerName, providersView, templatesView, schema, language } = input;
76
+ const providerConfig = providerName ? providersView?.providers[providerName] : null;
77
+ const providerSpec = templatesView?.providerTemplates.find((template) => template.providerType === providerConfig?.providerType);
78
+ const resolvedProviderConfig = providerConfig ?? EMPTY_PROVIDER_CONFIG;
79
+ const uiHints = schema?.uiHints;
80
+ const isCustomProvider = Boolean(providerConfig?.isCustom);
81
+ const apiKeyHint = providerName ? hintForPath(`providers.${providerName}.apiKey`, uiHints) : undefined;
82
+ const apiBaseHint = providerName ? hintForPath(`providers.${providerName}.apiBase`, uiHints) : undefined;
83
+ const extraHeadersHint = providerName ? hintForPath(`providers.${providerName}.extraHeaders`, uiHints) : undefined;
84
+ const wireApiHint = providerName ? hintForPath(`providers.${providerName}.wireApi`, uiHints) : undefined;
85
+ const defaultDisplayName = providerSpec?.displayName || providerConfig?.displayName || providerName || '';
86
+ const currentDisplayName = (resolvedProviderConfig.displayName || '').trim();
87
+ const effectiveDisplayName = currentDisplayName || defaultDisplayName;
88
+ const currentEnabled = resolvedProviderConfig.enabled !== false;
89
+ const providerModelAliases = normalizeModelList([providerSpec?.modelPrefix || providerSpec?.providerType || '', providerName || '']);
90
+ const defaultApiBase = providerSpec?.defaultApiBase || '';
91
+ const currentApiBase = resolvedProviderConfig.apiBase || defaultApiBase;
92
+ const currentHeaders = normalizeHeaders(resolvedProviderConfig.extraHeaders || null);
93
+ const currentWireApi = (resolvedProviderConfig.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
94
+ const defaultModels = normalizeModelList(
95
+ (providerSpec?.defaultModels ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
96
+ );
97
+ const currentModels = normalizeModelList(
98
+ (resolvedProviderConfig.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
99
+ );
100
+ const currentEditableModels = resolveEditableModels(defaultModels, currentModels);
101
+ const currentModelConfig = normalizeModelConfigForModels(
102
+ mergeModelConfig(providerSpec?.modelConfig, resolvedProviderConfig.modelConfig, providerModelAliases),
103
+ currentEditableModels
104
+ );
105
+ const apiBaseHelpText =
106
+ providerSpec?.apiBaseHelp?.[language] || providerSpec?.apiBaseHelp?.en || apiBaseHint?.help || t('providerApiBaseHelp');
107
+ const authContext = resolveProviderFormAuthContext({ providerName, providerSpec, language });
108
+ const wireApiContext = resolveProviderWireApiContext(providerSpec, isCustomProvider);
109
+
110
+ return {
111
+ apiBaseHelpText,
112
+ apiBaseHint,
113
+ apiKeyHint,
114
+ currentApiBase,
115
+ currentEditableModels,
116
+ currentEnabled,
117
+ currentHeaders,
118
+ currentModelConfig,
119
+ currentWireApi,
120
+ defaultApiBase,
121
+ defaultDisplayName,
122
+ effectiveDisplayName,
123
+ extraHeadersHint,
124
+ ...authContext,
125
+ providerConfig,
126
+ providerModelAliases,
127
+ resolvedProviderConfig,
128
+ wireApiHint,
129
+ ...wireApiContext
130
+ };
131
+ }