@nextclaw/ui 0.12.1 → 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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
  3. package/dist/assets/ChatPage-CslhBPfT.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-BZQW70ti.js +1 -0
  9. package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-DE0QjYVv.js} +1 -1
  10. package/dist/assets/{McpMarketplacePage-mz2_IX1O.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-B0RCb_Vg.js → ProvidersList-1rKi3aQT.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-CcfQjLtx.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-DbiS3txa.js → SecretsConfig-cnAXvREZ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BIXiDaK2.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-DqSv8Azv.js → index-8XNPYwJu.js} +3 -3
  30. package/dist/assets/index-CpxuJa9o.css +1 -0
  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-CGazBahs.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-D6HxybcM.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 +5 -5
  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 +112 -1
  57. package/src/components/agents/AgentsPage.tsx +104 -112
  58. package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
  59. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  60. package/src/components/chat/useChatSessionTypeState.ts +1 -1
  61. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  62. package/src/components/config/ChannelForm.test.tsx +60 -0
  63. package/src/components/config/ChannelForm.tsx +52 -12
  64. package/src/components/config/ModelConfig.test.tsx +61 -0
  65. package/src/components/config/ModelConfig.tsx +15 -90
  66. package/src/components/config/RuntimeConfig.tsx +2 -2
  67. package/src/components/config/SearchConfig.test.tsx +150 -0
  68. package/src/components/config/SearchConfig.tsx +257 -71
  69. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  70. package/src/hooks/agents/useAgents.ts +18 -1
  71. package/src/lib/i18n.agents.ts +19 -0
  72. package/src/lib/i18n.search.ts +37 -0
  73. package/src/lib/i18n.ts +6 -26
  74. package/dist/assets/ChannelsList-DekMP4a3.js +0 -8
  75. package/dist/assets/ChatPage-Dgw4vlDt.js +0 -43
  76. package/dist/assets/DocBrowser-CExjX5is.js +0 -1
  77. package/dist/assets/MarketplacePage-DGfzg1LG.js +0 -1
  78. package/dist/assets/ModelConfig-C_49_a9v.js +0 -1
  79. package/dist/assets/RuntimeConfig-DBWzwoY-.js +0 -1
  80. package/dist/assets/SearchConfig-jSdwlH4b.js +0 -1
  81. package/dist/assets/chat-session-display-8yW6-mtm.js +0 -1
  82. package/dist/assets/i18n-DAekxt_G.js +0 -1
  83. package/dist/assets/index-CHEgQIiO.css +0 -1
  84. package/dist/assets/loader-circle-CGXXikVG.js +0 -1
  85. package/dist/assets/plus-CrW9BJDy.js +0 -1
  86. package/dist/assets/provider-models-IJDA940D.js +0 -1
  87. package/dist/assets/search-DgoXxocn.js +0 -1
  88. package/dist/assets/skeleton-BiPUQkOD.js +0 -1
  89. package/dist/assets/x-PBSiWt3l.js +0 -1
@@ -1,39 +1,29 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
- import { useCreateAgent, useDeleteAgent, useAgents } from '@/hooks/agents/useAgents';
3
+ import { useCreateAgent, useDeleteAgent, useAgents, useUpdateAgent } from '@/hooks/agents/useAgents';
4
+ import { useConfig, useConfigMeta } from '@/hooks/useConfig';
5
+ import type { AgentProfileView } from '@/api/types';
6
+ import {
7
+ AgentCreateDialog,
8
+ AgentEditDialog,
9
+ type AgentCreateFormState,
10
+ type AgentEditFormState
11
+ } from '@/components/agents/AgentDialogs';
12
+ import {
13
+ buildSessionTypeOptions,
14
+ normalizeSessionType,
15
+ resolveSessionTypeLabel
16
+ } from '@/components/chat/useChatSessionTypeState';
4
17
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
18
  import { AgentAvatar } from '@/components/common/AgentAvatar';
6
19
  import { Button } from '@/components/ui/button';
7
20
  import { Card, CardContent } from '@/components/ui/card';
8
- import {
9
- Dialog,
10
- DialogContent,
11
- DialogDescription,
12
- DialogFooter,
13
- DialogHeader,
14
- DialogTitle
15
- } from '@/components/ui/dialog';
16
- import { Input } from '@/components/ui/input';
21
+ import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
17
22
  import { PageLayout } from '@/components/layout/page-layout';
18
23
  import { t } from '@/lib/i18n';
24
+ import { buildProviderModelCatalog } from '@/lib/provider-models';
19
25
  import { cn } from '@/lib/utils';
20
- import { Bot, House, MessageCircle, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
21
-
22
- type CreateFormState = {
23
- id: string;
24
- displayName: string;
25
- description: string;
26
- avatar: string;
27
- home: string;
28
- };
29
-
30
- const EMPTY_FORM: CreateFormState = {
31
- id: '',
32
- displayName: '',
33
- description: '',
34
- avatar: '',
35
- home: ''
36
- };
26
+ import { Bot, House, MessageCircle, Pencil, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
37
27
 
38
28
  const CARD_TONES = [
39
29
  {
@@ -63,10 +53,14 @@ function resolveAgentTone(index: number, builtIn: boolean) {
63
53
  export function AgentsPage() {
64
54
  const navigate = useNavigate();
65
55
  const agentsQuery = useAgents();
56
+ const configQuery = useConfig();
57
+ const configMetaQuery = useConfigMeta();
58
+ const sessionTypesQuery = useNcpChatSessionTypes();
66
59
  const createAgent = useCreateAgent();
60
+ const updateAgent = useUpdateAgent();
67
61
  const deleteAgent = useDeleteAgent();
68
62
  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
69
- const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);
63
+ const [editingAgent, setEditingAgent] = useState<AgentProfileView | null>(null);
70
64
  const setSessionListSnapshot = useChatSessionListStore((state) => state.setSnapshot);
71
65
 
72
66
  const agents = useMemo(() => agentsQuery.data?.agents ?? [], [agentsQuery.data?.agents]);
@@ -79,21 +73,56 @@ export function AgentsPage() {
79
73
  ),
80
74
  [agents]
81
75
  );
76
+ const providerCatalog = useMemo(
77
+ () => buildProviderModelCatalog({ config: configQuery.data, meta: configMetaQuery.data, onlyConfigured: true }),
78
+ [configMetaQuery.data, configQuery.data]
79
+ );
80
+ const runtimeOptions = useMemo(
81
+ () => buildSessionTypeOptions(sessionTypesQuery.data?.options ?? []),
82
+ [sessionTypesQuery.data?.options]
83
+ );
84
+ const defaultRuntime = useMemo(
85
+ () => normalizeSessionType(sessionTypesQuery.data?.defaultType ?? 'native'),
86
+ [sessionTypesQuery.data?.defaultType]
87
+ );
88
+ const defaultRuntimeLabel = useMemo(
89
+ () => runtimeOptions.find((option) => option.value === defaultRuntime)?.label ?? resolveSessionTypeLabel(defaultRuntime),
90
+ [defaultRuntime, runtimeOptions]
91
+ );
82
92
 
83
- const handleCreate = async () => {
93
+ const handleCreate = async (form: AgentCreateFormState) => {
84
94
  await createAgent.mutateAsync({
85
95
  data: {
86
96
  id: form.id,
87
97
  ...(form.displayName.trim() ? { displayName: form.displayName.trim() } : {}),
88
98
  ...(form.description.trim() ? { description: form.description.trim() } : {}),
89
99
  ...(form.avatar.trim() ? { avatar: form.avatar.trim() } : {}),
90
- ...(form.home.trim() ? { home: form.home.trim() } : {})
100
+ ...(form.home.trim() ? { home: form.home.trim() } : {}),
101
+ ...(form.model.trim() ? { model: form.model.trim() } : {}),
102
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : {})
91
103
  }
92
104
  });
93
- setForm(EMPTY_FORM);
94
105
  setIsCreateDialogOpen(false);
95
106
  };
96
107
 
108
+ const handleStartEdit = (agent: AgentProfileView) => {
109
+ setEditingAgent(agent);
110
+ };
111
+
112
+ const handleUpdate = async (agentId: string, form: AgentEditFormState) => {
113
+ await updateAgent.mutateAsync({
114
+ agentId,
115
+ data: {
116
+ displayName: form.displayName,
117
+ description: form.description,
118
+ avatar: form.avatar,
119
+ model: form.model,
120
+ ...(form.runtime.trim() ? { runtime: form.runtime.trim() } : { runtime: "" })
121
+ }
122
+ });
123
+ setEditingAgent(null);
124
+ };
125
+
97
126
  const startChatWithAgent = (agentId: string) => {
98
127
  setSessionListSnapshot({
99
128
  selectedAgentId: agentId,
@@ -178,6 +207,11 @@ export function AgentsPage() {
178
207
  ) : (
179
208
  sortedAgents.map((agent, index) => {
180
209
  const tone = resolveAgentTone(index, Boolean(agent.builtIn));
210
+ const runtimeValue = agent.runtime?.trim() || agent.engine?.trim() || '';
211
+ const runtimeLabel = runtimeValue
212
+ ? runtimeOptions.find((option) => option.value === normalizeSessionType(runtimeValue))?.label ??
213
+ resolveSessionTypeLabel(runtimeValue)
214
+ : defaultRuntimeLabel;
181
215
  return (
182
216
  <Card
183
217
  key={agent.id}
@@ -223,6 +257,16 @@ export function AgentsPage() {
223
257
  </p>
224
258
 
225
259
  <div className="mt-auto flex flex-col gap-4">
260
+ <div>
261
+ <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
262
+ <Sparkles className="h-3.5 w-3.5" />
263
+ {t('agentsCardRuntimeLabel')}
264
+ </div>
265
+ <div className="mt-1.5 text-sm leading-6 text-[#475569]">
266
+ {runtimeLabel}
267
+ </div>
268
+ </div>
269
+
226
270
  <div className="border-t border-gray-100 pt-3">
227
271
  <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
228
272
  <House className="h-3.5 w-3.5" />
@@ -242,6 +286,16 @@ export function AgentsPage() {
242
286
  <MessageCircle className="mr-2 h-4 w-4" />
243
287
  {t('agentsCardStartChat')}
244
288
  </Button>
289
+ <Button
290
+ type="button"
291
+ variant="ghost"
292
+ className="h-8 rounded-xl px-3 text-xs text-[#7b8794] hover:bg-[#f3f4f6] hover:text-[#475569]"
293
+ onClick={() => handleStartEdit(agent)}
294
+ disabled={updateAgent.isPending}
295
+ >
296
+ <Pencil className="mr-1.5 h-3.5 w-3.5" />
297
+ {t('agentsEditAction')}
298
+ </Button>
245
299
  {!agent.builtIn ? (
246
300
  <Button
247
301
  type="button"
@@ -263,91 +317,29 @@ export function AgentsPage() {
263
317
  )}
264
318
  </div>
265
319
 
266
- <Dialog
320
+ <AgentCreateDialog
267
321
  open={isCreateDialogOpen}
322
+ pending={createAgent.isPending}
323
+ providerCatalog={providerCatalog}
324
+ runtimeOptions={runtimeOptions}
325
+ defaultRuntime={defaultRuntime}
326
+ onOpenChange={setIsCreateDialogOpen}
327
+ onSubmit={handleCreate}
328
+ />
329
+
330
+ <AgentEditDialog
331
+ agent={editingAgent}
332
+ pending={updateAgent.isPending}
333
+ providerCatalog={providerCatalog}
334
+ runtimeOptions={runtimeOptions}
335
+ defaultRuntime={defaultRuntime}
268
336
  onOpenChange={(open) => {
269
- setIsCreateDialogOpen(open);
270
- if (!open && !createAgent.isPending) {
271
- setForm(EMPTY_FORM);
337
+ if (!open && !updateAgent.isPending) {
338
+ setEditingAgent(null);
272
339
  }
273
340
  }}
274
- >
275
- <DialogContent className="overflow-hidden border-none bg-[linear-gradient(180deg,#fff9f1_0%,#ffffff_24%)] p-0 sm:max-w-xl">
276
- <div className="border-b border-[#f0e2c8] px-6 py-6">
277
- <DialogHeader className="text-left">
278
- <DialogTitle>{t('agentsCreateDialogTitle')}</DialogTitle>
279
- <DialogDescription>{t('agentsCreateDialogDescription')}</DialogDescription>
280
- </DialogHeader>
281
- </div>
282
- <div className="space-y-4 px-6 py-6">
283
- <div className="grid gap-4 md:grid-cols-2">
284
- <Input
285
- value={form.id}
286
- onChange={(event) =>
287
- setForm((prev) => ({ ...prev, id: event.target.value }))
288
- }
289
- placeholder={t('agentsFormIdPlaceholder')}
290
- />
291
- <Input
292
- value={form.displayName}
293
- onChange={(event) =>
294
- setForm((prev) => ({
295
- ...prev,
296
- displayName: event.target.value
297
- }))
298
- }
299
- placeholder={t('agentsFormNamePlaceholder')}
300
- />
301
- <Input
302
- value={form.description}
303
- onChange={(event) =>
304
- setForm((prev) => ({
305
- ...prev,
306
- description: event.target.value
307
- }))
308
- }
309
- placeholder={t('agentsFormDescriptionPlaceholder')}
310
- />
311
- <Input
312
- value={form.avatar}
313
- onChange={(event) =>
314
- setForm((prev) => ({ ...prev, avatar: event.target.value }))
315
- }
316
- placeholder={t('agentsFormAvatarPlaceholder')}
317
- />
318
- <Input
319
- value={form.home}
320
- onChange={(event) =>
321
- setForm((prev) => ({ ...prev, home: event.target.value }))
322
- }
323
- placeholder={t('agentsFormHomePlaceholder')}
324
- />
325
- </div>
326
- <div className="rounded-2xl border border-[#efe3ca] bg-[#fff9ef] px-4 py-3 text-xs leading-6 text-[#7a6246]">
327
- {t('agentsCreateDialogHint')}
328
- </div>
329
- </div>
330
- <DialogFooter className="border-t border-[#f1e7d4] px-6 py-5">
331
- <Button
332
- type="button"
333
- variant="ghost"
334
- onClick={() => setIsCreateDialogOpen(false)}
335
- disabled={createAgent.isPending}
336
- >
337
- {t('cancel')}
338
- </Button>
339
- <Button
340
- type="button"
341
- className="rounded-2xl bg-[#1f5c4d] px-5 text-white hover:bg-[#184d40]"
342
- onClick={() => void handleCreate()}
343
- disabled={createAgent.isPending || form.id.trim().length === 0}
344
- >
345
- <Plus className="mr-2 h-4 w-4" />
346
- {t('agentsCreateAction')}
347
- </Button>
348
- </DialogFooter>
349
- </DialogContent>
350
- </Dialog>
341
+ onSubmit={handleUpdate}
342
+ />
351
343
  </PageLayout>
352
344
  );
353
345
  }
@@ -57,6 +57,7 @@ export function buildNcpSendMetadata(payload: {
57
57
  }
58
58
  if (payload.sessionType?.trim()) {
59
59
  metadata.session_type = payload.sessionType.trim();
60
+ metadata.runtime = payload.sessionType.trim();
60
61
  }
61
62
  if (payload.agentId?.trim()) {
62
63
  metadata.agent_id = payload.agentId.trim();
@@ -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) => {