@nextclaw/ui 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
  3. package/dist/assets/ChatPage-CslhBPfT.js +43 -0
  4. package/dist/assets/{DocBrowser-DxdSujSc.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CQ-8jMha.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D-KQIN4U.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/MarketplacePage-BZQW70ti.js +1 -0
  9. package/dist/assets/{MarketplacePage-CRNvxtvx.js → MarketplacePage-DE0QjYVv.js} +1 -1
  10. package/dist/assets/{McpMarketplacePage-Cu7GmCcc.js → McpMarketplacePage-CeLvv1xy.js} +1 -1
  11. package/dist/assets/ModelConfig-D1JtGtQv.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-SAJH6nkC.js +1 -0
  13. package/dist/assets/{ProvidersList-BWbUb7-2.js → ProvidersList-1rKi3aQT.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-NsawrZb0.js → RemoteAccessPage-bIAKxDky.js} +1 -1
  15. package/dist/assets/RuntimeConfig-BTk9319O.js +1 -0
  16. package/dist/assets/SearchConfig-EjeszXbv.js +1 -0
  17. package/dist/assets/{SecretsConfig-CgDZOd3w.js → SecretsConfig-cnAXvREZ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-Dd-KM7F7.js → SessionsConfig-BIXiDaK2.js} +1 -1
  19. package/dist/assets/{book-open-FnK2xCQd.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-B5l0hr_u.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-JKmXfZ3q.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-o1WWhwhd.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-C_moWYv7.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-DazA6Wd_.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BKje3SiD.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-DfW4DT8O.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/{index-BZaB1TqM.js → index-8XNPYwJu.js} +3 -3
  30. package/dist/assets/index-CpxuJa9o.css +1 -0
  31. package/dist/assets/{label-BzDWmdOe.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CTLlde_T.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-BagR3t59.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-5DWhNfd4.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-C3yu5yge.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BAJf-h7w.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-aa6z4GJL.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DRDxrApx.js → security-config-CGazBahs.js} +1 -1
  42. package/dist/assets/{select-BHJPiJWt.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-DUwsTIdv.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B6nCfcOB.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-B57SMElx.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-CrjYH5ok.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-DsxnXB1B.js → useConfirmDialog-D6HxybcM.js} +1 -1
  49. package/dist/assets/{useMutation-oTTWXgLG.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 +6 -6
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -4
  55. package/src/components/agents/AgentDialogs.tsx +400 -0
  56. package/src/components/agents/AgentsPage.test.tsx +112 -1
  57. package/src/components/agents/AgentsPage.tsx +104 -112
  58. package/src/components/chat/ChatConversationPanel.test.tsx +31 -0
  59. package/src/components/chat/ChatConversationPanel.tsx +7 -6
  60. package/src/components/chat/ChatSidebar.test.tsx +41 -1
  61. package/src/components/chat/ChatWelcome.test.tsx +7 -2
  62. package/src/components/chat/ChatWelcome.tsx +38 -35
  63. package/src/components/chat/chat-page-runtime.test.ts +30 -0
  64. package/src/components/chat/chat-sidebar-session-item.tsx +6 -2
  65. package/src/components/chat/ncp/NcpChatPage.tsx +23 -1
  66. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  67. package/src/components/chat/useChatSessionTypeState.ts +1 -1
  68. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  69. package/src/components/config/ChannelForm.test.tsx +60 -0
  70. package/src/components/config/ChannelForm.tsx +52 -12
  71. package/src/components/config/ModelConfig.test.tsx +61 -0
  72. package/src/components/config/ModelConfig.tsx +15 -90
  73. package/src/components/config/RuntimeConfig.tsx +3 -24
  74. package/src/components/config/SearchConfig.test.tsx +150 -0
  75. package/src/components/config/SearchConfig.tsx +257 -71
  76. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  77. package/src/hooks/agents/useAgents.ts +18 -1
  78. package/src/lib/i18n.agents.ts +21 -2
  79. package/src/lib/i18n.search.ts +37 -0
  80. package/src/lib/i18n.ts +6 -28
  81. package/dist/assets/ChannelsList-NKNKsf1J.js +0 -8
  82. package/dist/assets/ChatPage-p23OnnEI.js +0 -43
  83. package/dist/assets/DocBrowser-C8b2uPgL.js +0 -1
  84. package/dist/assets/MarketplacePage-GGkEXowp.js +0 -1
  85. package/dist/assets/ModelConfig-CEpx9fro.js +0 -1
  86. package/dist/assets/RuntimeConfig-BJHBsVTd.js +0 -1
  87. package/dist/assets/SearchConfig-BsaX_WYy.js +0 -1
  88. package/dist/assets/chat-session-display-BD_AN71I.js +0 -1
  89. package/dist/assets/i18n-BK1w-oBy.js +0 -1
  90. package/dist/assets/index-DaR9igPC.css +0 -1
  91. package/dist/assets/loader-circle-DdZPxBUz.js +0 -1
  92. package/dist/assets/plus-DP2PSCPO.js +0 -1
  93. package/dist/assets/provider-models-DJ29qHuA.js +0 -1
  94. package/dist/assets/search-pD6ZwQYF.js +0 -1
  95. package/dist/assets/skeleton-D6kCk9Y6.js +0 -1
  96. package/dist/assets/x-CTIQHUuD.js +0 -1
@@ -85,7 +85,12 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
85
85
  if (!metadata) {
86
86
  return 'native';
87
87
  }
88
- return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
88
+ return (
89
+ readOptionalString(metadata.runtime) ??
90
+ readOptionalString(metadata.session_type) ??
91
+ readOptionalString(metadata.sessionType) ??
92
+ 'native'
93
+ );
89
94
  }
90
95
 
91
96
  function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
@@ -55,7 +55,7 @@ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: str
55
55
  .join(' ') || sessionType;
56
56
  }
57
57
 
58
- function buildSessionTypeOptions(
58
+ export function buildSessionTypeOptions(
59
59
  options: ChatSessionTypeOptionView[]
60
60
  ): ChatSessionTypeOption[] {
61
61
  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"
@@ -38,7 +38,6 @@ export function RuntimeConfig() {
38
38
  const [agents, setAgents] = useState<AgentProfileView[]>([]);
39
39
  const [bindings, setBindings] = useState<AgentBindingView[]>([]);
40
40
  const [dmScope, setDmScope] = useState<DmScope>('per-channel-peer');
41
- const [maxPingPongTurns, setMaxPingPongTurns] = useState(0);
42
41
  const [defaultContextTokens, setDefaultContextTokens] = useState(200000);
43
42
  const [defaultEngine, setDefaultEngine] = useState('native');
44
43
 
@@ -49,14 +48,12 @@ export function RuntimeConfig() {
49
48
  setAgents((config.agents.list ?? []).map(hydrateRuntimeAgent));
50
49
  setBindings((config.bindings ?? []).map(hydrateRuntimeBinding));
51
50
  setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
52
- setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
53
51
  setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
54
52
  setDefaultEngine(config.agents.defaults.engine ?? 'native');
55
53
  }, [config]);
56
54
 
57
55
  const uiHints = schema?.uiHints;
58
56
  const dmScopeHint = hintForPath('session.dmScope', uiHints);
59
- const maxPingHint = hintForPath('session.agentToAgent.maxPingPongTurns', uiHints);
60
57
  const defaultContextTokensHint = hintForPath('agents.defaults.contextTokens', uiHints);
61
58
  const defaultEngineHint = hintForPath('agents.defaults.engine', uiHints);
62
59
  const agentContextTokensHint = hintForPath('agents.list.*.contextTokens', uiHints);
@@ -153,10 +150,7 @@ export function RuntimeConfig() {
153
150
  },
154
151
  bindings: normalizedBindings,
155
152
  session: {
156
- dmScope,
157
- agentToAgent: {
158
- maxPingPongTurns: Math.min(5, Math.max(0, maxPingPongTurns))
159
- }
153
+ dmScope
160
154
  }
161
155
  }
162
156
  });
@@ -223,21 +217,6 @@ export function RuntimeConfig() {
223
217
  </SelectContent>
224
218
  </Select>
225
219
  </div>
226
- <div className="space-y-2">
227
- <label className="text-sm font-medium text-gray-800">
228
- {maxPingHint?.label ?? t('maxPingPongTurns')}
229
- </label>
230
- <Input
231
- type="number"
232
- min={0}
233
- max={5}
234
- value={maxPingPongTurns}
235
- onChange={(event) => setMaxPingPongTurns(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
236
- />
237
- <p className="text-xs text-gray-500">
238
- {maxPingHint?.help ?? t('maxPingPongTurnsHelp')}
239
- </p>
240
- </div>
241
220
  </CardContent>
242
221
  </Card>
243
222
 
@@ -266,8 +245,8 @@ export function RuntimeConfig() {
266
245
  placeholder={t('modelOverridePlaceholder')}
267
246
  />
268
247
  <Input
269
- value={agent.engine ?? ''}
270
- onChange={(event) => updateAgent(index, { engine: event.target.value })}
248
+ value={agent.runtime ?? agent.engine ?? ''}
249
+ onChange={(event) => updateAgent(index, { runtime: event.target.value })}
271
250
  placeholder={agentEngineHint?.label ?? t('engineOverridePlaceholder')}
272
251
  />
273
252
  <div className="grid grid-cols-1 md:grid-cols-2 gap-2">