@nextclaw/ui 0.11.16 → 0.11.18

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 (108) hide show
  1. package/CHANGELOG.md +21 -2
  2. package/dist/assets/ChannelsList-eZfHzvxb.js +8 -0
  3. package/dist/assets/ChatPage-DKD5hcD8.js +38 -0
  4. package/dist/assets/DocBrowser-CIHLqoIm.js +1 -0
  5. package/dist/assets/DocBrowser-DKkE3Y4I.js +1 -0
  6. package/dist/assets/DocBrowserContext-BcZRBsCg.js +1 -0
  7. package/dist/assets/LogoBadge-BIPDLEwK.js +1 -0
  8. package/dist/assets/MarketplacePage-CMPjqEmN.js +1 -0
  9. package/dist/assets/MarketplacePage-D0iqC5o7.js +49 -0
  10. package/dist/assets/McpMarketplacePage-CCmRjGwl.js +40 -0
  11. package/dist/assets/ModelConfig-BiWp8Ymp.js +1 -0
  12. package/dist/assets/ProvidersList-HaCAzF9F.js +1 -0
  13. package/dist/assets/RemoteAccessPage-DOF4oEHW.js +1 -0
  14. package/dist/assets/RuntimeConfig-BnkWf6Eb.js +1 -0
  15. package/dist/assets/SearchConfig-3ofKM9W4.js +1 -0
  16. package/dist/assets/SecretsConfig-BRbC2hfo.js +3 -0
  17. package/dist/assets/SessionsConfig-BpoD_0WD.js +2 -0
  18. package/dist/assets/book-open-DzSduAaw.js +1 -0
  19. package/dist/assets/chat-session-display-CGfXhJoT.js +1 -0
  20. package/dist/assets/chunk-JZWAC4HX-C1vpvW4r.js +3 -0
  21. package/dist/assets/config-Df97LeLR.js +1 -0
  22. package/dist/assets/config-hints-fGnUjDe9.js +1 -0
  23. package/dist/assets/config-layout-B-7erZRN.js +1 -0
  24. package/dist/assets/createLucideIcon-CcR5wVoU.js +1 -0
  25. package/dist/assets/dist-BMlnBah3.js +1 -0
  26. package/dist/assets/dist-Dii9v3X9.js +15 -0
  27. package/dist/assets/external-link-CnSDrvJE.js +1 -0
  28. package/dist/assets/hash-CAnX6PNt.js +1 -0
  29. package/dist/assets/i18n-CXBpwAwA.js +1 -0
  30. package/dist/assets/index-CjPeKafH.js +6 -0
  31. package/dist/assets/index-DMy_fKKh.css +1 -0
  32. package/dist/assets/label-CtIFj7_6.js +1 -0
  33. package/dist/assets/loader-circle-qgU4zQDw.js +1 -0
  34. package/dist/assets/logos-3KFNiOej.js +1 -0
  35. package/dist/assets/marketplace-localization-CXeGRf6E.js +1 -0
  36. package/dist/assets/page-layout-BMwpn87D.js +1 -0
  37. package/dist/assets/plus-C9cYVbL-.js +1 -0
  38. package/dist/assets/popover-BIzq25oH.js +1 -0
  39. package/dist/assets/provider-models-C8JQUd1E.js +1 -0
  40. package/dist/assets/react-ji6GGP_j.js +1 -0
  41. package/dist/assets/save-CMgYkJ-y.js +1 -0
  42. package/dist/assets/search-sl1OeJFl.js +1 -0
  43. package/dist/assets/security-config-BcbOF17w.js +1 -0
  44. package/dist/assets/select-Cz82gl01.js +41 -0
  45. package/dist/assets/skeleton-rgIt7a5q.js +1 -0
  46. package/dist/assets/status-dot-C7q1HvLH.js +1 -0
  47. package/dist/assets/switch-DYswvkYj.js +1 -0
  48. package/dist/assets/tabs-custom-DKYQxrx1.js +1 -0
  49. package/dist/assets/trash-2-DfXI7-ap.js +1 -0
  50. package/dist/assets/useConfirmDialog-Dk15Fj1n.js +1 -0
  51. package/dist/assets/useMutation-s2sn2yzh.js +1 -0
  52. package/dist/assets/x-MIimOGs6.js +1 -0
  53. package/dist/index.html +18 -3
  54. package/package.json +9 -9
  55. package/src/api/types.ts +2 -0
  56. package/src/components/chat/ChatConversationPanel.tsx +1 -0
  57. package/src/components/chat/ChatSidebar.tsx +3 -21
  58. package/src/components/chat/adapters/chat-message.adapter.test.ts +12 -2
  59. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +46 -17
  60. package/src/components/chat/chat-session-display.test.ts +33 -0
  61. package/src/components/chat/chat-session-display.ts +15 -0
  62. package/src/components/chat/chat-sidebar-session-item.tsx +13 -4
  63. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  64. package/src/components/chat/ncp/ncp-chat-page-data.ts +2 -5
  65. package/src/components/chat/ncp/ncp-session-adapter.ts +21 -0
  66. package/src/components/chat/ncp/use-ncp-session-list-view.ts +2 -6
  67. package/src/components/common/session-context-icon.tsx +30 -0
  68. package/src/components/config/ChannelForm.tsx +71 -39
  69. package/src/components/config/SessionsConfig.tsx +2 -6
  70. package/src/components/config/channel-form-fields.test.ts +28 -0
  71. package/src/components/config/channel-form-fields.ts +95 -30
  72. package/src/components/config/weixin-channel-auth-section.test.tsx +26 -0
  73. package/src/components/config/weixin-channel-auth-section.tsx +6 -2
  74. package/src/lib/i18n.channel-auth.ts +5 -0
  75. package/src/lib/i18n.chat.ts +6 -0
  76. package/src/lib/session-context.utils.ts +95 -0
  77. package/src/transport/remote.transport.test.ts +1 -1
  78. package/vite.config.ts +2 -2
  79. package/dist/assets/ChannelsList-UKA-5t02.js +0 -8
  80. package/dist/assets/ChatPage-xdBp-ddG.js +0 -37
  81. package/dist/assets/DocBrowser-DBUIWJer.js +0 -1
  82. package/dist/assets/LogoBadge-CsSBHZeV.js +0 -1
  83. package/dist/assets/MarketplacePage-BSH836_G.js +0 -49
  84. package/dist/assets/McpMarketplacePage-B9_kPnnM.js +0 -40
  85. package/dist/assets/ModelConfig-DbEKVVg4.js +0 -1
  86. package/dist/assets/ProvidersList-Ck5PgTF2.js +0 -1
  87. package/dist/assets/RemoteAccessPage-4JED9IcK.js +0 -1
  88. package/dist/assets/RuntimeConfig-CB04ug9v.js +0 -1
  89. package/dist/assets/SearchConfig-DmuvL9Pn.js +0 -1
  90. package/dist/assets/SecretsConfig-Dw2sRiSs.js +0 -3
  91. package/dist/assets/SessionsConfig-c-Z9X3xH.js +0 -2
  92. package/dist/assets/chat-session-display-z9RvX-D3.js +0 -1
  93. package/dist/assets/config-hints-CApS3K_7.js +0 -1
  94. package/dist/assets/config-layout-BHnOoweL.js +0 -1
  95. package/dist/assets/index-CfVmBgkf.css +0 -1
  96. package/dist/assets/index-DqzLj8Sw.js +0 -8
  97. package/dist/assets/label-DPGDZvhm.js +0 -1
  98. package/dist/assets/marketplace-localization-Dk31LJJJ.js +0 -1
  99. package/dist/assets/page-layout-DMA_ZiHj.js +0 -1
  100. package/dist/assets/popover-BBLXwfva.js +0 -1
  101. package/dist/assets/provider-models-BOeNnjk9.js +0 -1
  102. package/dist/assets/security-config-FYNEE2eR.js +0 -1
  103. package/dist/assets/skeleton-DLM_39_P.js +0 -1
  104. package/dist/assets/status-dot-BOzEprxw.js +0 -1
  105. package/dist/assets/switch-rE_Ew8fl.js +0 -1
  106. package/dist/assets/tabs-custom-BkfMUTHE.js +0 -1
  107. package/dist/assets/useConfirmDialog-0owrqZcT.js +0 -1
  108. package/dist/assets/vendor-MCpnpiKt.js +0 -461
@@ -1,7 +1,9 @@
1
1
  import type { SessionEntryView } from '@/api/types';
2
+ import { SessionContextIconNode } from '@/components/common/session-context-icon';
2
3
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
3
4
  import { Button } from '@/components/ui/button';
4
5
  import { Input } from '@/components/ui/input';
6
+ import { type SessionContextView } from '@/lib/session-context.utils';
5
7
  import type { SessionRunStatus } from '@/lib/session-run-status';
6
8
  import { cn } from '@/lib/utils';
7
9
  import { formatDateTime, t } from '@/lib/i18n';
@@ -11,7 +13,7 @@ type ChatSidebarSessionItemProps = {
11
13
  session: SessionEntryView;
12
14
  active: boolean;
13
15
  runStatus?: SessionRunStatus;
14
- sessionTypeLabel: string | null;
16
+ context: SessionContextView;
15
17
  title: string;
16
18
  isEditing: boolean;
17
19
  draftLabel: string;
@@ -28,7 +30,7 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
28
30
  session,
29
31
  active,
30
32
  runStatus,
31
- sessionTypeLabel,
33
+ context,
32
34
  title,
33
35
  isEditing,
34
36
  draftLabel,
@@ -40,6 +42,8 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
40
42
  onCancel
41
43
  } = props;
42
44
 
45
+ const iconTone = active ? 'text-gray-700' : 'text-gray-500';
46
+
43
47
  return (
44
48
  <div
45
49
  className={cn(
@@ -102,7 +106,7 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
102
106
  <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5 pr-8">
103
107
  <span className="flex min-w-0 items-center gap-1.5">
104
108
  <span className="truncate font-medium">{title}</span>
105
- {sessionTypeLabel ? (
109
+ {context.label ? (
106
110
  <span
107
111
  className={cn(
108
112
  'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
@@ -111,7 +115,12 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
111
115
  : 'border-gray-200 bg-gray-100 text-gray-500'
112
116
  )}
113
117
  >
114
- {sessionTypeLabel}
118
+ {context.label}
119
+ </span>
120
+ ) : null}
121
+ {context.icon ? (
122
+ <span className="inline-flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center">
123
+ <SessionContextIconNode icon={context.icon} className={iconTone} />
115
124
  </span>
116
125
  ) : null}
117
126
  </span>
@@ -57,6 +57,8 @@ function buildChatMessageTexts(language: string) {
57
57
  return {
58
58
  copyCodeLabel: t("chatCodeCopy"),
59
59
  copiedCodeLabel: t("chatCodeCopied"),
60
+ copyMessageLabel: t("chatMessageCopy"),
61
+ copiedMessageLabel: t("chatMessageCopied"),
60
62
  typingLabel: t("chatTyping"),
61
63
  };
62
64
  }
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
2
2
  import type { Dispatch, SetStateAction } from 'react';
3
3
  import type { SessionEntryView, ThinkingLevel } from '@/api/types';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
+ import { sessionMatchesQuery } from '@/components/chat/chat-session-display';
5
6
  import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
6
7
  import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
7
8
  import {
@@ -30,11 +31,7 @@ type UseNcpChatPageDataParams = {
30
31
  };
31
32
 
32
33
  function filterSessionsByQuery(sessions: SessionEntryView[], query: string): SessionEntryView[] {
33
- const normalizedQuery = query.trim().toLowerCase();
34
- if (!normalizedQuery) {
35
- return sessions;
36
- }
37
- return sessions.filter((session) => session.key.toLowerCase().includes(normalizedQuery));
34
+ return sessions.filter((session) => sessionMatchesQuery(session, query));
38
35
  }
39
36
 
40
37
  export function filterModelOptionsBySessionType(params: {
@@ -76,6 +76,25 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
76
76
  return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
77
77
  }
78
78
 
79
+ function parseSessionContext(sessionKey: string): { channel?: string; type?: string } {
80
+ if (sessionKey === 'heartbeat') {
81
+ return { type: 'heartbeat' };
82
+ }
83
+ if (sessionKey.startsWith('cron:')) {
84
+ return { type: 'cron' };
85
+ }
86
+ if (sessionKey.startsWith('agent:')) {
87
+ const parts = sessionKey.split(':');
88
+ if (parts.length >= 3) {
89
+ const channel = parts[2];
90
+ if (channel && channel !== 'main' && channel !== 'direct') {
91
+ return { channel };
92
+ }
93
+ }
94
+ }
95
+ return {};
96
+ }
97
+
79
98
  function mapToolStatus(part: Extract<NcpMessagePart, { type: 'tool-invocation' }>): ToolInvocationStatus {
80
99
  if (part.state === 'result') {
81
100
  return ToolInvocationStatus.RESULT;
@@ -188,11 +207,13 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
188
207
  const label = readNcpSessionLabel(summary);
189
208
  const preferredModel = readNcpSessionPreferredModel(summary);
190
209
  const preferredThinking = readNcpSessionPreferredThinking(summary);
210
+ const context = parseSessionContext(summary.sessionId);
191
211
  return {
192
212
  key: summary.sessionId,
193
213
  createdAt: summary.updatedAt,
194
214
  updatedAt: summary.updatedAt,
195
215
  ...(label ? { label } : {}),
216
+ ...context,
196
217
  ...(preferredModel ? { preferredModel } : {}),
197
218
  ...(preferredThinking ? { preferredThinking } : {}),
198
219
  sessionType: readNcpSessionType(summary),
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
4
+ import { sessionMatchesQuery } from '@/components/chat/chat-session-display';
4
5
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
6
  import { useNcpSessions } from '@/hooks/useConfig';
6
7
  import type { SessionRunStatus } from '@/lib/session-run-status';
@@ -11,12 +12,7 @@ export type NcpSessionListItemView = {
11
12
  };
12
13
 
13
14
  function filterSessionsByQuery(sessions: readonly SessionEntryView[], query: string): SessionEntryView[] {
14
- const normalizedQuery = query.trim().toLowerCase();
15
- if (!normalizedQuery) {
16
- return [...sessions];
17
- }
18
-
19
- return sessions.filter((session) => session.key.toLowerCase().includes(normalizedQuery));
15
+ return sessions.filter((session) => sessionMatchesQuery(session, query));
20
16
  }
21
17
 
22
18
  export function useNcpSessionListView(params: { limit?: number } = {}) {
@@ -0,0 +1,30 @@
1
+ import { type SessionContextIcon } from '@/lib/session-context.utils';
2
+ import { LogoBadge } from '@/components/common/LogoBadge';
3
+ import { getChannelLogo } from '@/lib/logos';
4
+ import { cn } from '@/lib/utils';
5
+ import { AlarmClock, Bot, HeartPulse } from 'lucide-react';
6
+
7
+ export function SessionContextIconNode({ icon, className }: { icon: SessionContextIcon; className?: string }) {
8
+ if (icon.kind === 'channel-logo') {
9
+ return <ChannelLogoIcon channel={icon.channel} className={className} />;
10
+ }
11
+ if (icon.icon === 'heartbeat') {
12
+ return <HeartPulse className={cn('h-3.5 w-3.5', className)} />;
13
+ }
14
+ return <AlarmClock className={cn('h-3.5 w-3.5', className)} />;
15
+ }
16
+
17
+ function ChannelLogoIcon(
18
+ { channel, className }: { channel: string; className?: string }
19
+ ) {
20
+ const logoSrc = getChannelLogo(channel);
21
+ return (
22
+ <LogoBadge
23
+ name={channel}
24
+ src={logoSrc}
25
+ className={cn('h-4 w-4 rounded-[4px] border border-gray-200/80 bg-white', className)}
26
+ imgClassName="h-3 w-3 object-contain"
27
+ fallback={<Bot className="h-3 w-3 text-gray-500" />}
28
+ />
29
+ );
30
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useMemo, 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';
@@ -13,7 +13,7 @@ import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
13
13
  import { getChannelLogo } from '@/lib/logos';
14
14
  import { CONFIG_DETAIL_CARD_CLASS, CONFIG_EMPTY_DETAIL_CARD_CLASS } from './config-layout';
15
15
  import { ChannelFormFieldsSection } from './channel-form-fields-section';
16
- import { buildChannelFields } from './channel-form-fields';
16
+ import { buildChannelFormDefinitions, type ChannelField, type ChannelFormBlock, type ChannelFormFieldSection } from './channel-form-fields';
17
17
  import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
18
18
 
19
19
  type ChannelFormProps = {
@@ -50,6 +50,16 @@ function buildScopeDraft(scope: string, value: Record<string, unknown>): Record<
50
50
  return output;
51
51
  }
52
52
 
53
+ function resolveFieldsForSection(fields: ChannelField[], section: ChannelFormFieldSection): ChannelField[] {
54
+ if (section === 'all') {
55
+ return fields;
56
+ }
57
+ if (section === 'primary') {
58
+ return fields.filter((field) => field.section === 'primary');
59
+ }
60
+ return fields.filter((field) => field.section !== 'primary');
61
+ }
62
+
53
63
  export function ChannelForm({ channelName }: ChannelFormProps) {
54
64
  const { data: config } = useConfig();
55
65
  const { data: meta } = useConfigMeta();
@@ -62,7 +72,10 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
62
72
  const [runningActionId, setRunningActionId] = useState<string | null>(null);
63
73
 
64
74
  const channelConfig = channelName ? config?.channels[channelName] : null;
65
- const fields = channelName ? buildChannelFields()[channelName] ?? [] : [];
75
+ const channelDefinitions = useMemo(() => buildChannelFormDefinitions(), []);
76
+ const channelDefinition = channelName ? channelDefinitions[channelName] : undefined;
77
+ const fields = channelDefinition?.fields ?? [];
78
+ const layoutBlocks = channelDefinition?.layout ?? [{ type: 'fields', section: 'all' } satisfies ChannelFormBlock];
66
79
  const uiHints = schema?.uiHints;
67
80
  const scope = channelName ? `channels.${channelName}` : null;
68
81
  const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
@@ -71,14 +84,12 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
71
84
  : channelName;
72
85
  const channelMeta = meta?.channels.find((item) => item.name === channelName);
73
86
  const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
74
- const isWeixinChannel = channelName === 'weixin';
75
87
 
76
88
  useEffect(() => {
77
89
  if (channelConfig) {
78
90
  setFormData({ ...channelConfig });
79
91
  const nextDrafts: Record<string, string> = {};
80
- const currentFields = channelName ? buildChannelFields()[channelName] ?? [] : [];
81
- currentFields
92
+ fields
82
93
  .filter((field) => field.type === 'json')
83
94
  .forEach((field) => {
84
95
  const value = channelConfig[field.name];
@@ -89,7 +100,7 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
89
100
  setFormData({});
90
101
  setJsonDrafts({});
91
102
  }
92
- }, [channelConfig, channelName]);
103
+ }, [channelConfig, fields]);
93
104
 
94
105
  const updateField = (name: string, value: unknown) => {
95
106
  setFormData((prev) => ({ ...prev, [name]: value }));
@@ -193,7 +204,7 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
193
204
  );
194
205
  }
195
206
 
196
- const enabled = Boolean(channelConfig.enabled);
207
+ const enabled = typeof formData.enabled === 'boolean' ? formData.enabled : Boolean(channelConfig.enabled);
197
208
 
198
209
  return (
199
210
  <div className={CONFIG_DETAIL_CARD_CLASS}>
@@ -230,45 +241,66 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
230
241
 
231
242
  <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
232
243
  <div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-6 py-5">
233
- {isWeixinChannel ? (
234
- <>
235
- <WeixinChannelAuthSection
236
- channelConfig={channelConfig}
237
- formData={formData}
238
- disabled={updateChannel.isPending || Boolean(runningActionId)}
239
- />
240
- <details className="group rounded-2xl border border-gray-200/80 bg-white">
241
- <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4 text-sm font-medium text-gray-900">
242
- <div>
243
- <p>{t('weixinAuthAdvancedTitle')}</p>
244
- <p className="mt-1 text-xs font-normal text-gray-500">{t('weixinAuthAdvancedDescription')}</p>
245
- </div>
246
- <ChevronDown className="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180" />
247
- </summary>
248
- <div className="space-y-6 border-t border-gray-100 px-5 py-5">
244
+ {layoutBlocks.map((block, index) => {
245
+ if (block.type === 'fields') {
246
+ const blockFields = resolveFieldsForSection(fields, block.section);
247
+ if (blockFields.length === 0) {
248
+ return null;
249
+ }
250
+ if (!block.collapsible) {
251
+ return (
249
252
  <ChannelFormFieldsSection
253
+ key={`${block.type}-${block.section}-${index}`}
250
254
  channelName={channelName}
251
- fields={fields}
255
+ fields={blockFields}
252
256
  formData={formData}
253
257
  jsonDrafts={jsonDrafts}
254
258
  setJsonDrafts={setJsonDrafts}
255
259
  updateField={updateField}
256
260
  uiHints={uiHints}
257
261
  />
258
- </div>
259
- </details>
260
- </>
261
- ) : (
262
- <ChannelFormFieldsSection
263
- channelName={channelName}
264
- fields={fields}
265
- formData={formData}
266
- jsonDrafts={jsonDrafts}
267
- setJsonDrafts={setJsonDrafts}
268
- updateField={updateField}
269
- uiHints={uiHints}
270
- />
271
- )}
262
+ );
263
+ }
264
+ return (
265
+ <details key={`${block.type}-${block.section}-${index}`} className="group rounded-2xl border border-gray-200/80 bg-white">
266
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4 text-sm font-medium text-gray-900">
267
+ <div>
268
+ <p>{block.collapsible.title}</p>
269
+ {block.collapsible.description ? (
270
+ <p className="mt-1 text-xs font-normal text-gray-500">{block.collapsible.description}</p>
271
+ ) : null}
272
+ </div>
273
+ <ChevronDown className="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180" />
274
+ </summary>
275
+ <div className="space-y-6 border-t border-gray-100 px-5 py-5">
276
+ <ChannelFormFieldsSection
277
+ channelName={channelName}
278
+ fields={blockFields}
279
+ formData={formData}
280
+ jsonDrafts={jsonDrafts}
281
+ setJsonDrafts={setJsonDrafts}
282
+ updateField={updateField}
283
+ uiHints={uiHints}
284
+ />
285
+ </div>
286
+ </details>
287
+ );
288
+ }
289
+
290
+ if (block.sectionId === 'weixin-auth') {
291
+ return (
292
+ <WeixinChannelAuthSection
293
+ key={`${block.type}-${block.sectionId}-${index}`}
294
+ channelConfig={channelConfig}
295
+ formData={formData}
296
+ channelEnabled={enabled}
297
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
298
+ />
299
+ );
300
+ }
301
+
302
+ return null;
303
+ })}
272
304
  </div>
273
305
 
274
306
  <div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-6 py-4">
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
8
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
9
9
  import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
10
- import { sessionDisplayName } from '@/components/chat/chat-session-display';
10
+ import { sessionDisplayName, sessionMatchesQuery } from '@/components/chat/chat-session-display';
11
11
  import { cn } from '@/lib/utils';
12
12
  import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
13
13
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
@@ -167,15 +167,11 @@ export function SessionsConfig() {
167
167
  const sessionEntries = useMemo(() => adaptNcpSessionSummaries(sessionSummaries), [sessionSummaries]);
168
168
  const sessionSummaryById = useMemo(() => new Map(sessionSummaries.map((session) => [session.sessionId, session])), [sessionSummaries]);
169
169
  const filteredSessions = useMemo(() => {
170
- const normalizedQuery = query.trim().toLowerCase();
171
170
  return sessionEntries.filter((session) => {
172
171
  if (selectedChannel !== 'all' && resolveChannelFromSessionKey(session.key) !== selectedChannel) {
173
172
  return false;
174
173
  }
175
- if (!normalizedQuery) {
176
- return true;
177
- }
178
- return session.key.toLowerCase().includes(normalizedQuery) || sessionDisplayName(session).toLowerCase().includes(normalizedQuery);
174
+ return sessionMatchesQuery(session, query);
179
175
  });
180
176
  }, [query, selectedChannel, sessionEntries]);
181
177
  const selectedSession = useMemo(
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildChannelFormDefinitions } from './channel-form-fields';
3
+
4
+ describe('buildChannelFormDefinitions', () => {
5
+ it('keeps default channels on a single all-fields layout', () => {
6
+ const definitions = buildChannelFormDefinitions();
7
+
8
+ expect(definitions.telegram?.layout).toBeUndefined();
9
+ expect(definitions.telegram?.fields.some((field) => field.name === 'enabled')).toBe(true);
10
+ });
11
+
12
+ it('declares weixin layout blocks instead of relying on form-level branching', () => {
13
+ const definitions = buildChannelFormDefinitions();
14
+
15
+ expect(definitions.weixin?.layout).toEqual([
16
+ { type: 'fields', section: 'primary' },
17
+ { type: 'custom', sectionId: 'weixin-auth' },
18
+ {
19
+ type: 'fields',
20
+ section: 'advanced',
21
+ collapsible: {
22
+ title: 'Advanced settings',
23
+ description: 'Expand these fields only when you need to customize the API base URL, account mapping, or allowlist.'
24
+ }
25
+ }
26
+ ]);
27
+ });
28
+ });
@@ -2,7 +2,31 @@ import { t } from '@/lib/i18n';
2
2
 
3
3
  export type ChannelFieldType = 'boolean' | 'text' | 'email' | 'password' | 'number' | 'tags' | 'select' | 'json';
4
4
  export type ChannelOption = { value: string; label: string };
5
- export type ChannelField = { name: string; type: ChannelFieldType; label: string; options?: ChannelOption[] };
5
+ export type ChannelField = {
6
+ name: string;
7
+ type: ChannelFieldType;
8
+ label: string;
9
+ options?: ChannelOption[];
10
+ section?: 'primary' | 'advanced';
11
+ };
12
+ export type ChannelFormFieldSection = 'all' | 'primary' | 'advanced';
13
+ export type ChannelFormBlock =
14
+ | {
15
+ type: 'fields';
16
+ section: ChannelFormFieldSection;
17
+ collapsible?: {
18
+ title: string;
19
+ description?: string;
20
+ };
21
+ }
22
+ | {
23
+ type: 'custom';
24
+ sectionId: string;
25
+ };
26
+ export type ChannelFormDefinition = {
27
+ fields: ChannelField[];
28
+ layout?: ChannelFormBlock[];
29
+ };
6
30
 
7
31
  const DM_POLICY_OPTIONS: ChannelOption[] = [
8
32
  { value: 'pairing', label: 'pairing' },
@@ -24,9 +48,10 @@ const STREAMING_MODE_OPTIONS: ChannelOption[] = [
24
48
  { value: 'progress', label: 'progress' }
25
49
  ];
26
50
 
27
- export function buildChannelFields(): Record<string, ChannelField[]> {
51
+ export function buildChannelFormDefinitions(): Record<string, ChannelFormDefinition> {
28
52
  return {
29
- telegram: [
53
+ telegram: {
54
+ fields: [
30
55
  { name: 'enabled', type: 'boolean', label: t('enabled') },
31
56
  { name: 'token', type: 'password', label: t('botToken') },
32
57
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
@@ -38,8 +63,10 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
38
63
  { name: 'requireMention', type: 'boolean', label: t('requireMention') },
39
64
  { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
40
65
  { name: 'groups', type: 'json', label: t('groupRulesJson') }
41
- ],
42
- discord: [
66
+ ]
67
+ },
68
+ discord: {
69
+ fields: [
43
70
  { name: 'enabled', type: 'boolean', label: t('enabled') },
44
71
  { name: 'token', type: 'password', label: t('botToken') },
45
72
  { name: 'allowBots', type: 'boolean', label: t('allowBotMessages') },
@@ -58,13 +85,17 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
58
85
  { name: 'requireMention', type: 'boolean', label: t('requireMention') },
59
86
  { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
60
87
  { name: 'groups', type: 'json', label: t('groupRulesJson') }
61
- ],
62
- whatsapp: [
88
+ ]
89
+ },
90
+ whatsapp: {
91
+ fields: [
63
92
  { name: 'enabled', type: 'boolean', label: t('enabled') },
64
93
  { name: 'bridgeUrl', type: 'text', label: t('bridgeUrl') },
65
94
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
66
- ],
67
- feishu: [
95
+ ]
96
+ },
97
+ feishu: {
98
+ fields: [
68
99
  { name: 'enabled', type: 'boolean', label: t('enabled') },
69
100
  { name: 'appId', type: 'text', label: t('appId') },
70
101
  { name: 'appSecret', type: 'password', label: t('appSecret') },
@@ -79,14 +110,18 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
79
110
  { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
80
111
  { name: 'groups', type: 'json', label: t('groupRulesJson') },
81
112
  { name: 'accounts', type: 'json', label: t('accountsJson') }
82
- ],
83
- dingtalk: [
113
+ ]
114
+ },
115
+ dingtalk: {
116
+ fields: [
84
117
  { name: 'enabled', type: 'boolean', label: t('enabled') },
85
118
  { name: 'clientId', type: 'text', label: t('clientId') },
86
119
  { name: 'clientSecret', type: 'password', label: t('clientSecret') },
87
120
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
88
- ],
89
- wecom: [
121
+ ]
122
+ },
123
+ wecom: {
124
+ fields: [
90
125
  { name: 'enabled', type: 'boolean', label: t('enabled') },
91
126
  { name: 'corpId', type: 'text', label: t('corpId') },
92
127
  { name: 'agentId', type: 'text', label: t('agentId') },
@@ -95,24 +130,42 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
95
130
  { name: 'callbackPort', type: 'number', label: t('callbackPort') },
96
131
  { name: 'callbackPath', type: 'text', label: t('callbackPath') },
97
132
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
98
- ],
99
- weixin: [
100
- { name: 'enabled', type: 'boolean', label: t('enabled') },
101
- { name: 'defaultAccountId', type: 'text', label: t('defaultAccountId') },
102
- { name: 'baseUrl', type: 'text', label: t('baseUrl') },
103
- { name: 'pollTimeoutMs', type: 'number', label: t('pollTimeoutMs') },
104
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
105
- { name: 'accounts', type: 'json', label: t('accountsJson') }
106
- ],
107
- slack: [
133
+ ]
134
+ },
135
+ weixin: {
136
+ fields: [
137
+ { name: 'enabled', type: 'boolean', label: t('enabled'), section: 'primary' },
138
+ { name: 'defaultAccountId', type: 'text', label: t('defaultAccountId') },
139
+ { name: 'baseUrl', type: 'text', label: t('baseUrl') },
140
+ { name: 'pollTimeoutMs', type: 'number', label: t('pollTimeoutMs') },
141
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
142
+ { name: 'accounts', type: 'json', label: t('accountsJson') }
143
+ ],
144
+ layout: [
145
+ { type: 'fields', section: 'primary' },
146
+ { type: 'custom', sectionId: 'weixin-auth' },
147
+ {
148
+ type: 'fields',
149
+ section: 'advanced',
150
+ collapsible: {
151
+ title: t('weixinAuthAdvancedTitle'),
152
+ description: t('weixinAuthAdvancedDescription')
153
+ }
154
+ }
155
+ ]
156
+ },
157
+ slack: {
158
+ fields: [
108
159
  { name: 'enabled', type: 'boolean', label: t('enabled') },
109
160
  { name: 'mode', type: 'text', label: t('mode') },
110
161
  { name: 'webhookPath', type: 'text', label: t('webhookPath') },
111
162
  { name: 'allowBots', type: 'boolean', label: t('allowBotMessages') },
112
163
  { name: 'botToken', type: 'password', label: t('botToken') },
113
164
  { name: 'appToken', type: 'password', label: t('appToken') }
114
- ],
115
- email: [
165
+ ]
166
+ },
167
+ email: {
168
+ fields: [
116
169
  { name: 'enabled', type: 'boolean', label: t('enabled') },
117
170
  { name: 'consentGranted', type: 'boolean', label: t('consentGranted') },
118
171
  { name: 'imapHost', type: 'text', label: t('imapHost') },
@@ -120,20 +173,32 @@ export function buildChannelFields(): Record<string, ChannelField[]> {
120
173
  { name: 'imapUsername', type: 'text', label: t('imapUsername') },
121
174
  { name: 'imapPassword', type: 'password', label: t('imapPassword') },
122
175
  { name: 'fromAddress', type: 'email', label: t('fromAddress') }
123
- ],
124
- mochat: [
176
+ ]
177
+ },
178
+ mochat: {
179
+ fields: [
125
180
  { name: 'enabled', type: 'boolean', label: t('enabled') },
126
181
  { name: 'baseUrl', type: 'text', label: t('baseUrl') },
127
182
  { name: 'clawToken', type: 'password', label: t('clawToken') },
128
183
  { name: 'agentUserId', type: 'text', label: t('agentUserId') },
129
184
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
130
- ],
131
- qq: [
185
+ ]
186
+ },
187
+ qq: {
188
+ fields: [
132
189
  { name: 'enabled', type: 'boolean', label: t('enabled') },
133
190
  { name: 'appId', type: 'text', label: t('appId') },
134
191
  { name: 'secret', type: 'password', label: t('appSecret') },
135
192
  { name: 'markdownSupport', type: 'boolean', label: t('markdownSupport') },
136
193
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
137
- ]
194
+ ]
195
+ }
138
196
  };
139
197
  }
198
+
199
+ export function buildChannelFields(): Record<string, ChannelField[]> {
200
+ const definitions = buildChannelFormDefinitions();
201
+ return Object.fromEntries(
202
+ Object.entries(definitions).map(([channelName, definition]) => [channelName, definition.fields])
203
+ );
204
+ }
@@ -59,6 +59,7 @@ describe('WeixinChannelAuthSection', () => {
59
59
  <WeixinChannelAuthSection
60
60
  channelConfig={{ enabled: false }}
61
61
  formData={{}}
62
+ channelEnabled={false}
62
63
  />
63
64
  );
64
65
 
@@ -80,6 +81,7 @@ describe('WeixinChannelAuthSection', () => {
80
81
  }
81
82
  }}
82
83
  formData={{}}
84
+ channelEnabled={true}
83
85
  />
84
86
  );
85
87
 
@@ -88,4 +90,28 @@ describe('WeixinChannelAuthSection', () => {
88
90
  expect(screen.getByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
89
91
  });
90
92
  });
93
+
94
+ it('shows connected-but-inactive state when account is authorized but channel is disabled', async () => {
95
+ render(
96
+ <WeixinChannelAuthSection
97
+ channelConfig={{
98
+ enabled: false,
99
+ defaultAccountId: 'bot-1@im.bot',
100
+ accounts: {
101
+ 'bot-1@im.bot': {
102
+ enabled: true
103
+ }
104
+ }
105
+ }}
106
+ formData={{ enabled: false }}
107
+ channelEnabled={false}
108
+ />
109
+ );
110
+
111
+ expect(screen.getByText('Connected, but channel inactive')).toBeTruthy();
112
+ expect(
113
+ screen.getByText('This account is connected, but the channel is inactive. Turn on Enabled before it can send or receive messages.')
114
+ ).toBeTruthy();
115
+ expect(screen.getByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
116
+ });
91
117
  });