@nextclaw/ui 0.12.1 → 0.12.3

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 (95) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/assets/ChannelsList-DZWam3Ob.js +8 -0
  3. package/dist/assets/ChatPage-YBL7iJ1X.js +43 -0
  4. package/dist/assets/{DocBrowser-DjcghYGO.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CLlq7rZQ.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D_dOy5U3.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-2tWWgwAb.js} +1 -1
  9. package/dist/assets/MarketplacePage-BorWJftJ.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-mz2_IX1O.js → McpMarketplacePage-N-fB4HID.js} +1 -1
  11. package/dist/assets/ModelConfig-DvsBTUiE.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-D9woCARc.js +1 -0
  13. package/dist/assets/{ProvidersList-B0RCb_Vg.js → ProvidersList-D-qPGgC4.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-CcfQjLtx.js → RemoteAccessPage-COnjm8_x.js} +1 -1
  15. package/dist/assets/RuntimeConfig-BHpqcaHm.js +1 -0
  16. package/dist/assets/SearchConfig-DIT6M65Q.js +1 -0
  17. package/dist/assets/{SecretsConfig-DbiS3txa.js → SecretsConfig-Cefg1LFJ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BZnmVTIu.js} +1 -1
  19. package/dist/assets/{book-open-BLxSL7Dk.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-Bp0t5xoO.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-C96FWufn.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-B_U7Nq4F.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-D9pHzW9z.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-BFY-GyT4.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BydIQTIH.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-Djdf0x1C.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/index-CpxuJa9o.css +1 -0
  30. package/dist/assets/{index-DqSv8Azv.js → index-DHmCjcxq.js} +3 -3
  31. package/dist/assets/{label-Bvv4Mrea.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CGJJRI5_.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-6Nm4Cnvr.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-b9rSYI6X.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-CDZz_StC.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BvSSnnCw.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-CAf0_-b9.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DF66-l25.js → security-config-DEgOD4VX.js} +1 -1
  42. package/dist/assets/{select-CEIMqc0H.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-CmQI5Qq2.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B7SxDXyR.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-Dxt6EJJW.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-BnQ1PDTw.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-B-vMOmhG.js → useConfirmDialog-CuQqiPx7.js} +1 -1
  49. package/dist/assets/{useMutation-Bi39Z9_J.js → useMutation-DBTWPbTg.js} +1 -1
  50. package/dist/assets/x-B4sxJkGY.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +4 -4
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -1
  55. package/src/components/agents/AgentDialogs.tsx +400 -0
  56. package/src/components/agents/AgentsPage.test.tsx +148 -1
  57. package/src/components/agents/AgentsPage.tsx +114 -115
  58. package/src/components/chat/ChatConversationPanel.test.tsx +69 -3
  59. package/src/components/chat/ChatConversationPanel.tsx +24 -3
  60. package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -3
  61. package/src/components/chat/managers/chat-session-list.manager.ts +17 -4
  62. package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
  63. package/src/components/chat/ncp/ncp-chat.presenter.ts +5 -1
  64. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  65. package/src/components/chat/stores/chat-session-list.store.ts +6 -1
  66. package/src/components/chat/useChatSessionTypeState.ts +10 -2
  67. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  68. package/src/components/config/ChannelForm.test.tsx +60 -0
  69. package/src/components/config/ChannelForm.tsx +52 -12
  70. package/src/components/config/ModelConfig.test.tsx +61 -0
  71. package/src/components/config/ModelConfig.tsx +15 -90
  72. package/src/components/config/RuntimeConfig.tsx +2 -2
  73. package/src/components/config/SearchConfig.test.tsx +150 -0
  74. package/src/components/config/SearchConfig.tsx +257 -71
  75. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  76. package/src/hooks/agents/useAgents.ts +18 -1
  77. package/src/lib/i18n.agents.ts +19 -0
  78. package/src/lib/i18n.search.ts +37 -0
  79. package/src/lib/i18n.ts +6 -26
  80. package/dist/assets/ChannelsList-DekMP4a3.js +0 -8
  81. package/dist/assets/ChatPage-Dgw4vlDt.js +0 -43
  82. package/dist/assets/DocBrowser-CExjX5is.js +0 -1
  83. package/dist/assets/MarketplacePage-DGfzg1LG.js +0 -1
  84. package/dist/assets/ModelConfig-C_49_a9v.js +0 -1
  85. package/dist/assets/RuntimeConfig-DBWzwoY-.js +0 -1
  86. package/dist/assets/SearchConfig-jSdwlH4b.js +0 -1
  87. package/dist/assets/chat-session-display-8yW6-mtm.js +0 -1
  88. package/dist/assets/i18n-DAekxt_G.js +0 -1
  89. package/dist/assets/index-CHEgQIiO.css +0 -1
  90. package/dist/assets/loader-circle-CGXXikVG.js +0 -1
  91. package/dist/assets/plus-CrW9BJDy.js +0 -1
  92. package/dist/assets/provider-models-IJDA940D.js +0 -1
  93. package/dist/assets/search-DgoXxocn.js +0 -1
  94. package/dist/assets/skeleton-BiPUQkOD.js +0 -1
  95. package/dist/assets/x-PBSiWt3l.js +0 -1
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useMemo, useRef } from 'react';
2
2
  import type { Dispatch, SetStateAction } from 'react';
3
- import type { ChatSessionTypeOptionView, SessionEntryView } from '@/api/types';
3
+ import type { AgentProfileView, ChatSessionTypeOptionView, SessionEntryView } from '@/api/types';
4
4
  import { t } from '@/lib/i18n';
5
5
 
6
6
  export const DEFAULT_SESSION_TYPE = 'native';
@@ -39,6 +39,14 @@ export function normalizeSessionType(value: unknown): string {
39
39
  return normalized || DEFAULT_SESSION_TYPE;
40
40
  }
41
41
 
42
+ export function resolveAgentRuntimeSessionType(
43
+ agent: Pick<AgentProfileView, 'runtime' | 'engine'> | null | undefined,
44
+ fallbackSessionType: string = DEFAULT_SESSION_TYPE
45
+ ): string {
46
+ const runtime = agent?.runtime?.trim() || agent?.engine?.trim() || fallbackSessionType;
47
+ return normalizeSessionType(runtime);
48
+ }
49
+
42
50
  export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: string): string {
43
51
  if (sessionType === 'native') {
44
52
  return t('chatSessionTypeNative');
@@ -55,7 +63,7 @@ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: str
55
63
  .join(' ') || sessionType;
56
64
  }
57
65
 
58
- function buildSessionTypeOptions(
66
+ export function buildSessionTypeOptions(
59
67
  options: ChatSessionTypeOptionView[]
60
68
  ): ChatSessionTypeOption[] {
61
69
  const deduped = new Map<string, ChatSessionTypeOption>();
@@ -0,0 +1,149 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
3
+ import { SearchableModelInput } from '@/components/common/SearchableModelInput';
4
+ import { Input } from '@/components/ui/input';
5
+ import type { ProviderModelCatalogItem } from '@/lib/provider-models';
6
+ import { composeProviderModel, findProviderByModel, toProviderLocalModel } from '@/lib/provider-models';
7
+ import { t } from '@/lib/i18n';
8
+
9
+ type ProviderScopedModelInputProps = {
10
+ id?: string;
11
+ value: string;
12
+ onChange: (value: string) => void;
13
+ providerCatalog: ProviderModelCatalogItem[];
14
+ disabled?: boolean;
15
+ providerPlaceholder?: string;
16
+ modelPlaceholder?: string;
17
+ className?: string;
18
+ };
19
+
20
+ const DEFAULT_MODEL_INPUT_PLACEHOLDER = 'provider/model';
21
+
22
+ function normalizeModelOptions(options: string[]): string[] {
23
+ const deduped = new Set<string>();
24
+ for (const option of options) {
25
+ const trimmed = option.trim();
26
+ if (trimmed) {
27
+ deduped.add(trimmed);
28
+ }
29
+ }
30
+ return [...deduped];
31
+ }
32
+
33
+ export function ProviderScopedModelInput({
34
+ id,
35
+ value,
36
+ onChange,
37
+ providerCatalog,
38
+ disabled = false,
39
+ providerPlaceholder,
40
+ modelPlaceholder,
41
+ className
42
+ }: ProviderScopedModelInputProps) {
43
+ const [providerName, setProviderName] = useState('');
44
+ const [modelId, setModelId] = useState('');
45
+ const hasProviders = providerCatalog.length > 0;
46
+ const effectiveModelPlaceholder = modelPlaceholder ?? DEFAULT_MODEL_INPUT_PLACEHOLDER;
47
+
48
+ const providerMap = useMemo(
49
+ () => new Map(providerCatalog.map((provider) => [provider.name, provider])),
50
+ [providerCatalog]
51
+ );
52
+
53
+ const selectedProvider = providerMap.get(providerName);
54
+ const selectedProviderAliases = useMemo(() => selectedProvider?.aliases ?? [], [selectedProvider]);
55
+ const selectedProviderModels = useMemo(
56
+ () => normalizeModelOptions(selectedProvider?.models ?? []),
57
+ [selectedProvider]
58
+ );
59
+
60
+ useEffect(() => {
61
+ const currentModel = value.trim();
62
+ if (!hasProviders) {
63
+ setProviderName('');
64
+ setModelId(currentModel);
65
+ return;
66
+ }
67
+ const matchedProvider = findProviderByModel(currentModel, providerCatalog);
68
+ const effectiveProvider = matchedProvider ?? '';
69
+ const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
70
+ setProviderName(effectiveProvider);
71
+ setModelId(effectiveProvider ? toProviderLocalModel(currentModel, aliases) : currentModel);
72
+ }, [hasProviders, providerCatalog, providerMap, value]);
73
+
74
+ const handleProviderChange = (nextProvider: string) => {
75
+ setProviderName(nextProvider);
76
+ setModelId('');
77
+ onChange('');
78
+ };
79
+
80
+ const handleModelChange = (nextModelId: string) => {
81
+ if (!selectedProvider) {
82
+ const trimmed = nextModelId.trim();
83
+ setModelId(trimmed);
84
+ onChange(trimmed);
85
+ return;
86
+ }
87
+ const normalizedLocalModel = toProviderLocalModel(nextModelId, selectedProviderAliases);
88
+ setModelId(normalizedLocalModel);
89
+ onChange(normalizedLocalModel ? composeProviderModel(selectedProvider.prefix, normalizedLocalModel) : '');
90
+ };
91
+
92
+ if (!hasProviders) {
93
+ return (
94
+ <div className={className}>
95
+ <div className="rounded-2xl border border-amber-200 bg-amber-50/70 px-4 py-3">
96
+ <p className="text-sm font-semibold text-amber-950">{t('providersEmptyTitle')}</p>
97
+ <p className="mt-1 text-xs leading-5 text-amber-900">{t('providersEmptyDescription')}</p>
98
+ </div>
99
+ <Input
100
+ id={id}
101
+ value={value}
102
+ disabled={disabled}
103
+ onChange={(event) => onChange(event.target.value)}
104
+ placeholder={effectiveModelPlaceholder}
105
+ className="mt-3 h-10 rounded-xl"
106
+ />
107
+ <p className="mt-2 text-xs text-gray-500">{t('modelInputCustomHint')}</p>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div className={className}>
114
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
115
+ <div className="sm:w-[38%] sm:min-w-[170px]">
116
+ <Select value={providerName} onValueChange={handleProviderChange} disabled={disabled}>
117
+ <SelectTrigger className="h-10 w-full rounded-xl">
118
+ <SelectValue placeholder={providerPlaceholder ?? t('providersSelectPlaceholder')} />
119
+ </SelectTrigger>
120
+ <SelectContent>
121
+ {providerCatalog.map((provider) => (
122
+ <SelectItem key={provider.name} value={provider.name}>
123
+ {provider.displayName}
124
+ </SelectItem>
125
+ ))}
126
+ </SelectContent>
127
+ </Select>
128
+ </div>
129
+ <span className="hidden h-10 items-center justify-center leading-none text-lg font-semibold text-gray-300 sm:inline-flex">
130
+ /
131
+ </span>
132
+ <SearchableModelInput
133
+ key={providerName}
134
+ id={id}
135
+ value={modelId}
136
+ onChange={handleModelChange}
137
+ options={selectedProviderModels}
138
+ disabled={disabled || !providerName}
139
+ placeholder={effectiveModelPlaceholder}
140
+ className="sm:flex-1"
141
+ inputClassName="h-10 rounded-xl"
142
+ emptyText={t('modelPickerNoOptions')}
143
+ createText={t('modelPickerUseCustom')}
144
+ />
145
+ </div>
146
+ <p className="mt-2 text-xs text-gray-500">{t('modelInputCustomHint')}</p>
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,60 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import { ChannelForm } from './ChannelForm';
3
+
4
+ vi.mock('@/hooks/useConfig', () => ({
5
+ useConfig: () => ({
6
+ data: {
7
+ channels: {
8
+ weixin: {
9
+ enabled: false
10
+ }
11
+ }
12
+ }
13
+ }),
14
+ useConfigMeta: () => ({
15
+ data: {
16
+ channels: [
17
+ {
18
+ name: 'weixin',
19
+ displayName: 'Weixin',
20
+ enabled: false
21
+ }
22
+ ]
23
+ }
24
+ }),
25
+ useConfigSchema: () => ({
26
+ data: {
27
+ uiHints: {},
28
+ actions: []
29
+ }
30
+ }),
31
+ useUpdateChannel: () => ({
32
+ mutate: vi.fn(),
33
+ mutateAsync: vi.fn(),
34
+ isPending: false
35
+ }),
36
+ useExecuteConfigAction: () => ({
37
+ mutateAsync: vi.fn(),
38
+ isPending: false
39
+ })
40
+ }));
41
+
42
+ describe('ChannelForm', () => {
43
+ it('renders the empty selection state without entering a render loop', async () => {
44
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
45
+
46
+ render(<ChannelForm />);
47
+
48
+ expect(await screen.findByText('Select a channel from the left to configure')).toBeTruthy();
49
+
50
+ await waitFor(() => {
51
+ expect(
52
+ consoleErrorSpy.mock.calls.some((call) =>
53
+ call.some((entry) => typeof entry === 'string' && entry.includes('Maximum update depth exceeded'))
54
+ )
55
+ ).toBe(false);
56
+ });
57
+
58
+ consoleErrorSpy.mockRestore();
59
+ });
60
+ });
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { StatusDot } from '@/components/ui/status-dot';
@@ -20,6 +20,9 @@ type ChannelFormProps = {
20
20
  channelName?: string;
21
21
  };
22
22
 
23
+ const EMPTY_CHANNEL_FIELDS: ChannelField[] = [];
24
+ const DEFAULT_CHANNEL_LAYOUT_BLOCKS: ChannelFormBlock[] = [{ type: 'fields', section: 'all' }];
25
+
23
26
  function isRecord(value: unknown): value is Record<string, unknown> {
24
27
  return typeof value === 'object' && value !== null && !Array.isArray(value);
25
28
  }
@@ -60,6 +63,31 @@ function resolveFieldsForSection(fields: ChannelField[], section: ChannelFormFie
60
63
  return fields.filter((field) => field.section !== 'primary');
61
64
  }
62
65
 
66
+ function buildJsonDrafts(channelConfig: Record<string, unknown>, fields: ChannelField[]): Record<string, string> {
67
+ const nextDrafts: Record<string, string> = {};
68
+ fields
69
+ .filter((field) => field.type === 'json')
70
+ .forEach((field) => {
71
+ nextDrafts[field.name] = JSON.stringify(channelConfig[field.name] ?? {}, null, 2);
72
+ });
73
+ return nextDrafts;
74
+ }
75
+
76
+ function buildChannelFormHydrationKey(
77
+ channelName: string | undefined,
78
+ channelConfig: Record<string, unknown> | null | undefined,
79
+ fields: ChannelField[]
80
+ ): string {
81
+ if (!channelName || !channelConfig) {
82
+ return `empty:${channelName ?? ''}`;
83
+ }
84
+ return JSON.stringify({
85
+ channelName,
86
+ channelConfig,
87
+ jsonFields: fields.filter((field) => field.type === 'json').map((field) => field.name)
88
+ });
89
+ }
90
+
63
91
  export function ChannelForm({ channelName }: ChannelFormProps) {
64
92
  const { data: config } = useConfig();
65
93
  const { data: meta } = useConfigMeta();
@@ -70,12 +98,13 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
70
98
  const [formData, setFormData] = useState<Record<string, unknown>>({});
71
99
  const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
72
100
  const [runningActionId, setRunningActionId] = useState<string | null>(null);
101
+ const lastHydrationKeyRef = useRef<string | null>(null);
73
102
 
74
103
  const channelConfig = channelName ? config?.channels[channelName] : null;
75
104
  const channelDefinitions = useMemo(() => buildChannelFormDefinitions(), []);
76
105
  const channelDefinition = channelName ? channelDefinitions[channelName] : undefined;
77
- const fields = channelDefinition?.fields ?? [];
78
- const layoutBlocks = channelDefinition?.layout ?? [{ type: 'fields', section: 'all' } satisfies ChannelFormBlock];
106
+ const fields = channelDefinition?.fields ?? EMPTY_CHANNEL_FIELDS;
107
+ const layoutBlocks = channelDefinition?.layout ?? DEFAULT_CHANNEL_LAYOUT_BLOCKS;
79
108
  const uiHints = schema?.uiHints;
80
109
  const scope = channelName ? `channels.${channelName}` : null;
81
110
  const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
@@ -84,23 +113,22 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
84
113
  : channelName;
85
114
  const channelMeta = meta?.channels.find((item) => item.name === channelName);
86
115
  const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
116
+ const hydrationKey = buildChannelFormHydrationKey(channelName, channelConfig, fields);
87
117
 
88
118
  useEffect(() => {
119
+ if (lastHydrationKeyRef.current === hydrationKey) {
120
+ return;
121
+ }
122
+ lastHydrationKeyRef.current = hydrationKey;
123
+
89
124
  if (channelConfig) {
90
125
  setFormData({ ...channelConfig });
91
- const nextDrafts: Record<string, string> = {};
92
- fields
93
- .filter((field) => field.type === 'json')
94
- .forEach((field) => {
95
- const value = channelConfig[field.name];
96
- nextDrafts[field.name] = JSON.stringify(value ?? {}, null, 2);
97
- });
98
- setJsonDrafts(nextDrafts);
126
+ setJsonDrafts(buildJsonDrafts(channelConfig, fields));
99
127
  } else {
100
128
  setFormData({});
101
129
  setJsonDrafts({});
102
130
  }
103
- }, [channelConfig, fields]);
131
+ }, [channelConfig, fields, hydrationKey]);
104
132
 
105
133
  const updateField = (name: string, value: unknown) => {
106
134
  setFormData((prev) => ({ ...prev, [name]: value }));
@@ -150,6 +178,18 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
150
178
  return;
151
179
  }
152
180
  setFormData((prev) => deepMergeRecords(prev, channelPatch));
181
+ setJsonDrafts((prev) => {
182
+ let changed = false;
183
+ const nextDrafts = { ...prev };
184
+ for (const field of fields) {
185
+ if (field.type !== 'json' || !Object.prototype.hasOwnProperty.call(channelPatch, field.name)) {
186
+ continue;
187
+ }
188
+ nextDrafts[field.name] = JSON.stringify(channelPatch[field.name] ?? {}, null, 2);
189
+ changed = true;
190
+ }
191
+ return changed ? nextDrafts : prev;
192
+ });
153
193
  };
154
194
 
155
195
  const handleManualAction = async (action: ConfigActionManifest) => {
@@ -1,6 +1,7 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { ModelConfig } from '@/components/config/ModelConfig';
4
+ import { setLanguage } from '@/lib/i18n';
4
5
 
5
6
  const mocks = vi.hoisted(() => ({
6
7
  mutate: vi.fn(),
@@ -56,6 +57,34 @@ vi.mock('@/hooks/useConfig', () => ({
56
57
  describe('ModelConfig', () => {
57
58
  beforeEach(() => {
58
59
  mocks.mutate.mockReset();
60
+ setLanguage('en');
61
+ mocks.configQuery.data = {
62
+ agents: {
63
+ defaults: {
64
+ model: 'openai/gpt-5.2',
65
+ workspace: '~/old-workspace'
66
+ }
67
+ },
68
+ providers: {
69
+ openai: {
70
+ enabled: true,
71
+ apiKeySet: true,
72
+ models: ['gpt-5.2']
73
+ }
74
+ }
75
+ };
76
+ mocks.metaQuery.data = {
77
+ providers: [
78
+ {
79
+ name: 'openai',
80
+ displayName: 'OpenAI',
81
+ modelPrefix: 'openai',
82
+ defaultModels: ['openai/gpt-5.2'],
83
+ keywords: [],
84
+ envKey: 'OPENAI_API_KEY'
85
+ }
86
+ ]
87
+ };
59
88
  });
60
89
 
61
90
  it('submits the workspace together with the selected model', async () => {
@@ -75,4 +104,36 @@ describe('ModelConfig', () => {
75
104
  });
76
105
  });
77
106
  });
107
+
108
+ it('shows a clear empty state and still allows manual model input when no providers are configured', async () => {
109
+ const user = userEvent.setup();
110
+ mocks.configQuery.data = {
111
+ agents: {
112
+ defaults: {
113
+ model: '',
114
+ workspace: '~/workspace'
115
+ }
116
+ },
117
+ providers: {}
118
+ } as typeof mocks.configQuery.data;
119
+ mocks.metaQuery.data = {
120
+ providers: []
121
+ } as typeof mocks.metaQuery.data;
122
+
123
+ render(<ModelConfig />);
124
+
125
+ expect(await screen.findByText('No providers configured')).toBeTruthy();
126
+ expect(screen.getByText('Add an AI provider to start using the platform.')).toBeTruthy();
127
+
128
+ const modelInput = screen.getByPlaceholderText('provider/model');
129
+ await user.type(modelInput, 'openai/gpt-5.1');
130
+ await user.click(screen.getByRole('button', { name: /save/i }));
131
+
132
+ await waitFor(() => {
133
+ expect(mocks.mutate).toHaveBeenCalledWith({
134
+ model: 'openai/gpt-5.1',
135
+ workspace: '~/workspace'
136
+ });
137
+ });
138
+ });
78
139
  });
@@ -3,30 +3,25 @@ import { Card } from '@/components/ui/card';
3
3
  import { Input } from '@/components/ui/input';
4
4
  import { Label } from '@/components/ui/label';
5
5
  import { Skeleton } from '@/components/ui/skeleton';
6
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
- import { SearchableModelInput } from '@/components/common/SearchableModelInput';
6
+ import { ProviderScopedModelInput } from '@/components/common/ProviderScopedModelInput';
8
7
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
9
8
  import { hintForPath } from '@/lib/config-hints';
10
9
  import { t } from '@/lib/i18n';
11
- import {
12
- buildProviderModelCatalog,
13
- composeProviderModel,
14
- findProviderByModel,
15
- toProviderLocalModel
16
- } from '@/lib/provider-models';
10
+ import { buildProviderModelCatalog } from '@/lib/provider-models';
17
11
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
18
12
  import { DOCS_DEFAULT_BASE_URL } from '@/components/doc-browser/DocBrowserContext';
19
13
  import { BookOpen, Folder, Loader2, Sparkles } from 'lucide-react';
20
14
  import { useEffect, useMemo, useState } from 'react';
21
15
 
16
+ const DEFAULT_MODEL_INPUT_PLACEHOLDER = 'provider/model';
17
+
22
18
  export function ModelConfig() {
23
19
  const { data: config, isLoading } = useConfig();
24
20
  const { data: meta } = useConfigMeta();
25
21
  const { data: schema } = useConfigSchema();
26
22
  const updateModel = useUpdateModel();
27
23
 
28
- const [providerName, setProviderName] = useState('');
29
- const [modelId, setModelId] = useState('');
24
+ const [model, setModel] = useState('');
30
25
  const [workspace, setWorkspace] = useState('');
31
26
  const uiHints = schema?.uiHints;
32
27
  const modelHint = hintForPath('agents.defaults.model', uiHints);
@@ -37,66 +32,20 @@ export function ModelConfig() {
37
32
  [config, meta]
38
33
  );
39
34
 
40
- const providerMap = useMemo(() => new Map(providerCatalog.map((provider) => [provider.name, provider])), [providerCatalog]);
41
- const selectedProvider = providerMap.get(providerName);
42
- const selectedProviderName = selectedProvider?.name ?? '';
43
- const selectedProviderAliases = useMemo(() => selectedProvider?.aliases ?? [], [selectedProvider]);
44
- const selectedProviderModels = useMemo(() => selectedProvider?.models ?? [], [selectedProvider]);
45
-
46
35
  useEffect(() => {
47
36
  if (!config?.agents?.defaults) {
48
37
  return;
49
38
  }
50
- const currentModel = (config.agents.defaults.model || '').trim();
51
- const matchedProvider = findProviderByModel(currentModel, providerCatalog);
52
- const effectiveProvider = matchedProvider ?? '';
53
- const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
54
- setProviderName(effectiveProvider);
55
- setModelId(effectiveProvider ? toProviderLocalModel(currentModel, aliases) : '');
39
+ setModel((config.agents.defaults.model || '').trim());
56
40
  setWorkspace(config.agents.defaults.workspace || '');
57
- }, [config, providerCatalog, providerMap]);
58
-
59
- const modelOptions = useMemo(() => {
60
- const deduped = new Set<string>();
61
- for (const modelName of selectedProviderModels) {
62
- const trimmed = modelName.trim();
63
- if (trimmed) {
64
- deduped.add(trimmed);
65
- }
66
- }
67
- return [...deduped];
68
- }, [selectedProviderModels]);
69
-
70
- const composedModel = useMemo(() => {
71
- if (!selectedProvider) {
72
- return '';
73
- }
74
- const normalizedModelId = toProviderLocalModel(modelId, selectedProviderAliases);
75
- if (!normalizedModelId) {
76
- return '';
77
- }
78
- return composeProviderModel(selectedProvider.prefix, normalizedModelId);
79
- }, [modelId, selectedProvider, selectedProviderAliases]);
41
+ }, [config]);
80
42
 
81
43
  const modelHelpText = t('modelIdentifierHelp') || modelHint?.help || '';
82
44
 
83
- const handleProviderChange = (nextProvider: string) => {
84
- setProviderName(nextProvider);
85
- setModelId('');
86
- };
87
-
88
- const handleModelChange = (nextModelId: string) => {
89
- if (!selectedProvider) {
90
- setModelId('');
91
- return;
92
- }
93
- setModelId(toProviderLocalModel(nextModelId, selectedProviderAliases));
94
- };
95
-
96
45
  const handleSubmit = (e: React.FormEvent) => {
97
46
  e.preventDefault();
98
47
  updateModel.mutate({
99
- model: composedModel,
48
+ model,
100
49
  workspace
101
50
  });
102
51
  };
@@ -146,38 +95,14 @@ export function ModelConfig() {
146
95
  <Label htmlFor="model" className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
147
96
  {modelHint?.label ?? 'Model Name'}
148
97
  </Label>
149
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
150
- <div className="sm:w-[38%] sm:min-w-[170px]">
151
- <Select value={selectedProviderName} onValueChange={handleProviderChange}>
152
- <SelectTrigger className="h-10 w-full rounded-xl">
153
- <SelectValue placeholder={t('providersSelectPlaceholder')} />
154
- </SelectTrigger>
155
- <SelectContent>
156
- {providerCatalog.map((provider) => (
157
- <SelectItem key={provider.name} value={provider.name}>
158
- {provider.displayName}
159
- </SelectItem>
160
- ))}
161
- </SelectContent>
162
- </Select>
163
- </div>
164
- <span className="hidden h-10 items-center justify-center leading-none text-lg font-semibold text-gray-300 sm:inline-flex">/</span>
165
- <SearchableModelInput
166
- key={selectedProviderName}
167
- id="model"
168
- value={modelId}
169
- onChange={handleModelChange}
170
- options={modelOptions}
171
- disabled={!selectedProviderName}
172
- placeholder={modelHint?.placeholder ?? 'gpt-5.1'}
173
- className="sm:flex-1"
174
- inputClassName="h-10 rounded-xl"
175
- emptyText={t('modelPickerNoOptions')}
176
- createText={t('modelPickerUseCustom')}
177
- />
178
- </div>
98
+ <ProviderScopedModelInput
99
+ id="model"
100
+ value={model}
101
+ onChange={setModel}
102
+ providerCatalog={providerCatalog}
103
+ modelPlaceholder={modelHint?.placeholder ?? DEFAULT_MODEL_INPUT_PLACEHOLDER}
104
+ />
179
105
  <p className="text-xs text-gray-400">{modelHelpText}</p>
180
- <p className="text-xs text-gray-500">{t('modelInputCustomHint')}</p>
181
106
  <a
182
107
  href={`${DOCS_DEFAULT_BASE_URL}/guide/model-selection`}
183
108
  className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover transition-colors"
@@ -245,8 +245,8 @@ export function RuntimeConfig() {
245
245
  placeholder={t('modelOverridePlaceholder')}
246
246
  />
247
247
  <Input
248
- value={agent.engine ?? ''}
249
- onChange={(event) => updateAgent(index, { engine: event.target.value })}
248
+ value={agent.runtime ?? agent.engine ?? ''}
249
+ onChange={(event) => updateAgent(index, { runtime: event.target.value })}
250
250
  placeholder={agentEngineHint?.label ?? t('engineOverridePlaceholder')}
251
251
  />
252
252
  <div className="grid grid-cols-1 md:grid-cols-2 gap-2">