@nextclaw/ui 0.12.33 → 0.12.35-beta.0

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 (133) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/assets/{api-rASx2-N4.js → api-De1PAch0.js} +3 -3
  3. package/dist/assets/{book-open-BcnAiKde.js → book-open-825Pcd0z.js} +1 -1
  4. package/dist/assets/channels-list-page-CGjXDSD-.js +8 -0
  5. package/dist/assets/chat-page-RsUTK_bY.js +105 -0
  6. package/dist/assets/{config-split-page-CcrEUtwu.js → config-split-page-BvfxFQSb.js} +1 -1
  7. package/dist/assets/{createLucideIcon-DzY6wN61.js → createLucideIcon-CeNHY6BW.js} +1 -1
  8. package/dist/assets/desktop-update-config-B_YIv5ZJ.js +1 -0
  9. package/dist/assets/{dist-NBjcwlsw.js → dist-uH7tiW6E.js} +1 -1
  10. package/dist/assets/{doc-browser-D2pq4IHR.js → doc-browser-CDe9yz9n.js} +1 -1
  11. package/dist/assets/doc-browser-DwT64GuF.js +1 -0
  12. package/dist/assets/{doc-browser-context-BPeeiNJ_.js → doc-browser-context-CQAx_92e.js} +1 -1
  13. package/dist/assets/{es2015-CVAuMqlM.js → es2015-c9XYpli5.js} +1 -1
  14. package/dist/assets/{external-link-CpEvG65F.js → external-link-BE1deZ_c.js} +1 -1
  15. package/dist/assets/i18n-DnubSHoK.js +1 -0
  16. package/dist/assets/index-6WZ6JjaU.css +1 -0
  17. package/dist/assets/index-BjH_p3R6.js +103 -0
  18. package/dist/assets/{key-round-DUq47t0P.js → key-round-CHo3gqpO.js} +1 -1
  19. package/dist/assets/mcp-marketplace-page-0C214hGi.js +40 -0
  20. package/dist/assets/mcp-marketplace-page-amWcMncu.js +1 -0
  21. package/dist/assets/model-config-B8zNrkag.js +1 -0
  22. package/dist/assets/notice-card-DO33_LzD.js +5 -0
  23. package/dist/assets/play-CqlWbVNo.js +1 -0
  24. package/dist/assets/plus-p61tQh2-.js +1 -0
  25. package/dist/assets/provider-scoped-model-input-B0_r_4EY.js +1 -0
  26. package/dist/assets/providers-list-B6v3foi7.js +1 -0
  27. package/dist/assets/remote-k671rLmY.js +1 -0
  28. package/dist/assets/{rotate-cw-BN9yjccP.js → rotate-cw-CYUByuqo.js} +1 -1
  29. package/dist/assets/runtime-config-page-DgH-XJwK.js +1 -0
  30. package/dist/assets/{save-CO_4qf6b.js → save-B6Ga8JIq.js} +1 -1
  31. package/dist/assets/{search-CRtQwr-h.js → search-5kKpgNoP.js} +1 -1
  32. package/dist/assets/search-config-DPZ6d9y6.js +1 -0
  33. package/dist/assets/{secrets-config-DFbHCb9n.js → secrets-config-CrXA11bo.js} +2 -2
  34. package/dist/assets/{select-HI5kiwX_.js → select-C0hGOCOK.js} +1 -1
  35. package/dist/assets/{setting-row-B4QnghjV.js → setting-row-xsM_0nTd.js} +1 -1
  36. package/dist/assets/{tabs-custom-D48zdZoc.js → tabs-custom-Bnqfhe4p.js} +1 -1
  37. package/dist/assets/{tag-chip-BPgMfp9l.js → tag-chip-cktJJMzh.js} +1 -1
  38. package/dist/assets/x-DDQw-C2x.js +1 -0
  39. package/dist/index.html +17 -29
  40. package/package.json +9 -9
  41. package/src/app/components/layout/app-layout.test.tsx +49 -4
  42. package/src/app/components/layout/app-layout.tsx +4 -7
  43. package/src/app/components/layout/settings-entry-page.test.tsx +9 -0
  44. package/src/app/components/layout/sidebar.layout.test.tsx +2 -2
  45. package/src/app/components/layout/sidebar.tsx +3 -3
  46. package/src/app/configs/app-navigation.config.ts +0 -12
  47. package/src/app/hooks/use-app-event-consumers.ts +1 -6
  48. package/src/app/hooks/use-viewport-layout.ts +0 -1
  49. package/src/app/index.tsx +2 -10
  50. package/src/features/channels/components/config/channel-form.tsx +85 -47
  51. package/src/features/channels/components/config/weixin-channel-auth-section.test.tsx +45 -1
  52. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +119 -1
  53. package/src/features/channels/hooks/use-channel-auth.ts +8 -1
  54. package/src/features/channels/index.ts +1 -1
  55. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +2 -2
  56. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -7
  57. package/src/features/chat/components/layout/chat-sidebar.test.tsx +27 -3
  58. package/src/features/chat/components/layout/chat-sidebar.tsx +2 -3
  59. package/src/features/chat/managers/chat-session-list.manager.test.ts +12 -11
  60. package/src/features/chat/managers/chat-session-list.manager.ts +2 -1
  61. package/src/features/chat/managers/chat-ui.manager.ts +2 -2
  62. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +3 -3
  63. package/src/features/chat/pages/ncp-chat-page.test.ts +3 -0
  64. package/src/features/chat/pages/ncp-chat-page.tsx +19 -19
  65. package/src/features/chat/utils/chat-session-route.utils.test.ts +14 -0
  66. package/src/features/chat/utils/chat-session-route.utils.ts +3 -1
  67. package/src/features/chat/utils/ncp-chat-runtime-availability.utils.test.ts +6 -6
  68. package/src/features/marketplace/components/marketplace-list-card.tsx +4 -36
  69. package/src/features/marketplace/components/marketplace-page-detail.test.tsx +0 -1
  70. package/src/features/marketplace/components/marketplace-page.test.tsx +9 -128
  71. package/src/features/marketplace/components/marketplace-page.tsx +16 -72
  72. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +1 -1
  73. package/src/features/marketplace/hooks/use-marketplace.ts +1 -10
  74. package/src/features/marketplace/utils/marketplace-installed-cache.utils.test.ts +23 -21
  75. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +12 -12
  76. package/src/features/system-status/managers/system-status.manager.test.ts +6 -6
  77. package/src/index.css +1 -1
  78. package/src/platforms/desktop/components/desktop-app-shell.test.tsx +29 -13
  79. package/src/platforms/desktop/components/desktop-app-shell.tsx +14 -16
  80. package/src/platforms/desktop/components/desktop-window-chrome.tsx +2 -3
  81. package/src/platforms/mobile/components/mobile-app-shell.tsx +3 -1
  82. package/src/platforms/mobile/components/mobile-topbar.tsx +6 -5
  83. package/src/platforms/mobile/index.ts +1 -0
  84. package/src/shared/components/config/provider-form-support.ts +51 -33
  85. package/src/shared/components/config/provider-form.tsx +68 -35
  86. package/src/shared/components/config/provider-models-section.tsx +19 -4
  87. package/src/shared/components/model-config.test.tsx +2 -0
  88. package/src/shared/components/ui/button.tsx +1 -1
  89. package/src/shared/lib/api/channel-auth.types.ts +8 -0
  90. package/src/shared/lib/api/types.ts +17 -20
  91. package/src/shared/lib/api/utils/channel-auth.utils.ts +10 -1
  92. package/src/shared/lib/api/utils/marketplace.utils.ts +0 -5
  93. package/src/shared/lib/i18n/channel-auth.constants.ts +24 -10
  94. package/src/shared/lib/i18n/chat-labels.utils.ts +2 -2
  95. package/src/shared/lib/i18n/index.ts +2 -0
  96. package/src/shared/lib/i18n/marketplace.ts +0 -16
  97. package/src/shared/lib/provider-models/index.ts +41 -13
  98. package/src/shared/lib/ui-document-title/index.ts +0 -1
  99. package/dist/assets/app-manager-provider-CUkcbdWk.js +0 -1
  100. package/dist/assets/channels-list-page-ZWYqfcCe.js +0 -8
  101. package/dist/assets/chat-page-ACm58eUO.js +0 -1
  102. package/dist/assets/cpu-DKc3bQic.js +0 -3
  103. package/dist/assets/desktop-C-cDRCBY.js +0 -3
  104. package/dist/assets/desktop-update-config-DAD9JN9Q.js +0 -1
  105. package/dist/assets/dialog-CR80q3Rm.js +0 -5
  106. package/dist/assets/doc-browser-DhIkcy0-.js +0 -1
  107. package/dist/assets/doc-browser-fLs0TcdS.js +0 -1
  108. package/dist/assets/download-CMM8po31.js +0 -1
  109. package/dist/assets/i18n-D1144VAA.js +0 -1
  110. package/dist/assets/index-DV57tBmg.css +0 -1
  111. package/dist/assets/index-Db9Cufsy.js +0 -100
  112. package/dist/assets/marketplace-page-CAp_gpNe.js +0 -105
  113. package/dist/assets/marketplace-page-PPY5uMHK.js +0 -1
  114. package/dist/assets/mcp-marketplace-page-87__92b1.js +0 -1
  115. package/dist/assets/mcp-marketplace-page-C6HENB6V.js +0 -40
  116. package/dist/assets/model-config-CK3a8DWH.js +0 -1
  117. package/dist/assets/notice-card-CF4AJPM3.js +0 -1
  118. package/dist/assets/play-AqrNslHI.js +0 -1
  119. package/dist/assets/plus-B-YHtTNC.js +0 -1
  120. package/dist/assets/popover-DeAliE_4.js +0 -1
  121. package/dist/assets/provider-scoped-model-input-B8LJ6RCt.js +0 -1
  122. package/dist/assets/providers-list-BTPjqNdK.js +0 -1
  123. package/dist/assets/refresh-cw-CrbD8EkT.js +0 -1
  124. package/dist/assets/remote-DiPWGRZc.js +0 -1
  125. package/dist/assets/runtime-config-page-Bb9he7t8.js +0 -1
  126. package/dist/assets/search-config-BkbSXa9d.js +0 -1
  127. package/dist/assets/settings-MWL2SMyk.js +0 -1
  128. package/dist/assets/sparkles-BmgOD4nY.js +0 -1
  129. package/dist/assets/status-dot-l3kPFdq_.js +0 -1
  130. package/dist/assets/use-config-BFqzVF8Y.js +0 -1
  131. package/dist/assets/use-infinite-scroll-loader-DXhw9W4l.js +0 -1
  132. package/dist/assets/x-BeyYA_h6.js +0 -1
  133. /package/dist/assets/{config-hints-BNfpOL4J.js → config-hints-DGfwbumO.js} +0 -0
@@ -10,7 +10,7 @@ import { nextclawClient } from '@/shared/lib/api';
10
10
  import { useConfig, useConfigMeta, useConfigSchema, useExecuteConfigAction, useUpdateChannel } from '@/shared/hooks/use-config';
11
11
  import type { ConfigActionManifest, ConfigUiHints } from '@/shared/lib/api';
12
12
  import { ChannelFormFieldsSection } from '@/features/channels/components/channel-form-fields-section';
13
- import { QrChannelAuthSection } from '@/features/channels/components/config/weixin-channel-auth-section';
13
+ import { FeishuChannelAuthSection, QrChannelAuthSection } from '@/features/channels/components/config/weixin-channel-auth-section';
14
14
  import { buildChannelFormDefinitions, type ChannelField, type ChannelFormBlock, type ChannelFormFieldSection } from '@/features/channels/utils/channel-form-fields.utils';
15
15
  import { ConfigSplitDetailPane, ConfigSplitEmptyPane, ConfigSplitPaneBody, ConfigSplitPaneFooter, ConfigSplitPaneHeader } from '@/shared/components/config-split-page';
16
16
  import { hintForPath } from '@/shared/lib/config-hints';
@@ -121,7 +121,13 @@ function buildChannelApplyStatusView(channelApplyState: ChannelApplyState) {
121
121
  return { className: 'text-red-600', label: `${t('channelConfigApplyFailed')}${channelApplyState.message ? `: ${channelApplyState.message}` : ''}` };
122
122
  }
123
123
 
124
- function ChannelFormHeader(props: {
124
+ function ChannelFormHeader({
125
+ channelApplyStatus,
126
+ channelLabel,
127
+ channelName,
128
+ enabled,
129
+ tutorialUrl
130
+ }: {
125
131
  channelApplyStatus: ReturnType<typeof buildChannelApplyStatusView>;
126
132
  channelLabel?: string;
127
133
  channelName: string;
@@ -134,30 +140,42 @@ function ChannelFormHeader(props: {
134
140
  <div className="min-w-0">
135
141
  <div className="flex items-center gap-3">
136
142
  <LogoBadge
137
- name={props.channelName}
138
- src={getChannelLogo(props.channelName)}
139
- className={cn('h-9 w-9 rounded-lg border', props.enabled ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white')}
143
+ name={channelName}
144
+ src={getChannelLogo(channelName)}
145
+ className={cn('h-9 w-9 rounded-lg border', enabled ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white')}
140
146
  imgClassName="h-5 w-5 object-contain"
141
- fallback={<span className="text-sm font-semibold uppercase text-gray-500">{props.channelName[0]}</span>}
147
+ fallback={<span className="text-sm font-semibold uppercase text-gray-500">{channelName[0]}</span>}
142
148
  />
143
- <h3 className="truncate text-lg font-semibold text-gray-900 capitalize">{props.channelLabel}</h3>
149
+ <h3 className="truncate text-lg font-semibold text-gray-900 capitalize">{channelLabel}</h3>
144
150
  </div>
145
151
  <p className="mt-2 text-sm text-gray-500">{t('channelsFormDescription')}</p>
146
- {props.channelApplyStatus ? <p className={cn('mt-2 text-xs font-medium', props.channelApplyStatus.className)}>{props.channelApplyStatus.label}</p> : null}
147
- {props.tutorialUrl ? (
148
- <a href={props.tutorialUrl} className="mt-2 inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover">
152
+ {channelApplyStatus ? <p className={cn('mt-2 text-xs font-medium', channelApplyStatus.className)}>{channelApplyStatus.label}</p> : null}
153
+ {tutorialUrl ? (
154
+ <a href={tutorialUrl} className="mt-2 inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover">
149
155
  <BookOpen className="h-3.5 w-3.5" />
150
156
  {t('channelsGuideTitle')}
151
157
  </a>
152
158
  ) : null}
153
159
  </div>
154
- <StatusDot status={props.enabled ? 'active' : 'inactive'} label={props.enabled ? t('statusActive') : t('statusInactive')} />
160
+ <StatusDot status={enabled ? 'active' : 'inactive'} label={enabled ? t('statusActive') : t('statusInactive')} />
155
161
  </div>
156
162
  </ConfigSplitPaneHeader>
157
163
  );
158
164
  }
159
165
 
160
- function ChannelFormBlocks(props: {
166
+ function ChannelFormBlocks({
167
+ channelConfig,
168
+ channelName,
169
+ disabled,
170
+ enabled,
171
+ fields,
172
+ formData,
173
+ jsonDrafts,
174
+ layoutBlocks,
175
+ setJsonDrafts,
176
+ uiHints,
177
+ updateField
178
+ }: {
161
179
  channelConfig: Record<string, unknown>;
162
180
  channelName: string;
163
181
  enabled: boolean;
@@ -172,21 +190,21 @@ function ChannelFormBlocks(props: {
172
190
  }) {
173
191
  return (
174
192
  <>
175
- {props.layoutBlocks.map((block, index) => {
193
+ {layoutBlocks.map((block, index) => {
176
194
  if (block.type === 'fields') {
177
- const blockFields = resolveFieldsForSection(props.fields, block.section);
195
+ const blockFields = resolveFieldsForSection(fields, block.section);
178
196
  if (blockFields.length === 0) {
179
197
  return null;
180
198
  }
181
199
  const content = (
182
200
  <ChannelFormFieldsSection
183
- channelName={props.channelName}
201
+ channelName={channelName}
184
202
  fields={blockFields}
185
- formData={props.formData}
186
- jsonDrafts={props.jsonDrafts}
187
- setJsonDrafts={props.setJsonDrafts}
188
- updateField={props.updateField}
189
- uiHints={props.uiHints}
203
+ formData={formData}
204
+ jsonDrafts={jsonDrafts}
205
+ setJsonDrafts={setJsonDrafts}
206
+ updateField={updateField}
207
+ uiHints={uiHints}
190
208
  />
191
209
  );
192
210
  if (!block.collapsible) {
@@ -205,14 +223,25 @@ function ChannelFormBlocks(props: {
205
223
  </details>
206
224
  );
207
225
  }
208
- return block.sectionId === 'weixin-auth' || block.sectionId === 'feishu-auth' ? (
226
+ if (block.sectionId === 'feishu-auth') {
227
+ return (
228
+ <FeishuChannelAuthSection
229
+ key={`${block.type}-${block.sectionId}-${index}`}
230
+ channelConfig={channelConfig}
231
+ formData={formData}
232
+ channelEnabled={enabled}
233
+ disabled={disabled}
234
+ />
235
+ );
236
+ }
237
+ return block.sectionId === 'weixin-auth' ? (
209
238
  <QrChannelAuthSection
210
239
  key={`${block.type}-${block.sectionId}-${index}`}
211
- channelConfig={props.channelConfig}
212
- formData={props.formData}
213
- channelName={props.channelName === 'feishu' ? 'feishu' : 'weixin'}
214
- channelEnabled={props.enabled}
215
- disabled={props.disabled}
240
+ channelConfig={channelConfig}
241
+ formData={formData}
242
+ channelName="weixin"
243
+ channelEnabled={enabled}
244
+ disabled={disabled}
216
245
  />
217
246
  ) : null;
218
247
  })}
@@ -220,14 +249,23 @@ function ChannelFormBlocks(props: {
220
249
  );
221
250
  }
222
251
 
223
- function ChannelFormEditor(props: ChannelFormEditorProps) {
252
+ function ChannelFormEditor({
253
+ actions,
254
+ channelConfig,
255
+ channelLabel,
256
+ channelName,
257
+ fields,
258
+ layoutBlocks,
259
+ tutorialUrl,
260
+ uiHints
261
+ }: ChannelFormEditorProps) {
224
262
  const updateChannel = useUpdateChannel();
225
263
  const executeAction = useExecuteConfigAction();
226
- const [formData, setFormData] = useState<Record<string, unknown>>(() => ({ ...props.channelConfig }));
227
- const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>(() => buildJsonDrafts(props.channelConfig, props.fields));
264
+ const [formData, setFormData] = useState<Record<string, unknown>>(() => ({ ...channelConfig }));
265
+ const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>(() => buildJsonDrafts(channelConfig, fields));
228
266
  const [runningActionId, setRunningActionId] = useState<string | null>(null);
229
- const channelApplyStatus = buildChannelApplyStatusView(useChannelApplyState(props.channelName));
230
- const enabled = typeof formData.enabled === 'boolean' ? formData.enabled : Boolean(props.channelConfig.enabled);
267
+ const channelApplyStatus = buildChannelApplyStatusView(useChannelApplyState(channelName));
268
+ const enabled = typeof formData.enabled === 'boolean' ? formData.enabled : Boolean(channelConfig.enabled);
231
269
  const disabled = updateChannel.isPending || Boolean(runningActionId);
232
270
 
233
271
  const updateField = (name: string, value: unknown) => setFormData((prev) => ({ ...prev, [name]: value }));
@@ -235,7 +273,7 @@ function ChannelFormEditor(props: ChannelFormEditorProps) {
235
273
  const handleSubmit = (event: FormEvent) => {
236
274
  event.preventDefault();
237
275
  const payload: Record<string, unknown> = { ...formData };
238
- for (const field of props.fields) {
276
+ for (const field of fields) {
239
277
  if (field.type === 'password') {
240
278
  const value = payload[field.name];
241
279
  if (typeof value !== 'string' || value.length === 0) {
@@ -251,19 +289,19 @@ function ChannelFormEditor(props: ChannelFormEditorProps) {
251
289
  }
252
290
  }
253
291
  }
254
- updateChannel.mutate({ channel: props.channelName, data: payload });
292
+ updateChannel.mutate({ channel: channelName, data: payload });
255
293
  };
256
294
 
257
295
  const applyActionPatchToForm = (patch?: Record<string, unknown>) => {
258
- if (!isRecord(patch?.channels) || !isRecord(patch.channels[props.channelName])) {
296
+ if (!isRecord(patch?.channels) || !isRecord(patch.channels[channelName])) {
259
297
  return;
260
298
  }
261
- const channelPatch = patch.channels[props.channelName] as Record<string, unknown>;
299
+ const channelPatch = patch.channels[channelName] as Record<string, unknown>;
262
300
  setFormData((prev) => deepMergeRecords(prev, channelPatch));
263
301
  setJsonDrafts((prev) => {
264
302
  const nextDrafts = { ...prev };
265
303
  let changed = false;
266
- for (const field of props.fields) {
304
+ for (const field of fields) {
267
305
  if (field.type !== 'json' || !Object.prototype.hasOwnProperty.call(channelPatch, field.name)) {
268
306
  continue;
269
307
  }
@@ -277,12 +315,12 @@ function ChannelFormEditor(props: ChannelFormEditorProps) {
277
315
  const handleManualAction = async (action: ConfigActionManifest) => {
278
316
  setRunningActionId(action.id);
279
317
  try {
280
- const scope = `channels.${props.channelName}`;
318
+ const scope = `channels.${channelName}`;
281
319
  let nextData = { ...formData };
282
320
  if (action.saveBeforeRun) {
283
321
  nextData = { ...nextData, ...(action.savePatch ?? {}) };
284
322
  setFormData(nextData);
285
- await updateChannel.mutateAsync({ channel: props.channelName, data: nextData });
323
+ await updateChannel.mutateAsync({ channel: channelName, data: nextData });
286
324
  }
287
325
  const result = await executeAction.mutateAsync({ actionId: action.id, data: { scope, draftConfig: buildScopeDraft(scope, nextData) } });
288
326
  applyActionPatchToForm(result.patch);
@@ -301,25 +339,25 @@ function ChannelFormEditor(props: ChannelFormEditorProps) {
301
339
  return (
302
340
  <ConfigSplitDetailPane>
303
341
  <ChannelFormHeader
304
- channelName={props.channelName}
305
- channelLabel={props.channelLabel}
342
+ channelName={channelName}
343
+ channelLabel={channelLabel}
306
344
  enabled={enabled}
307
- tutorialUrl={props.tutorialUrl}
345
+ tutorialUrl={tutorialUrl}
308
346
  channelApplyStatus={channelApplyStatus}
309
347
  />
310
348
 
311
349
  <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
312
350
  <ConfigSplitPaneBody className="space-y-6 px-6 py-5">
313
351
  <ChannelFormBlocks
314
- channelConfig={props.channelConfig}
315
- channelName={props.channelName}
352
+ channelConfig={channelConfig}
353
+ channelName={channelName}
316
354
  enabled={enabled}
317
- fields={props.fields}
355
+ fields={fields}
318
356
  formData={formData}
319
357
  jsonDrafts={jsonDrafts}
320
- layoutBlocks={props.layoutBlocks}
358
+ layoutBlocks={layoutBlocks}
321
359
  setJsonDrafts={setJsonDrafts}
322
- uiHints={props.uiHints}
360
+ uiHints={uiHints}
323
361
  updateField={updateField}
324
362
  disabled={disabled}
325
363
  />
@@ -327,7 +365,7 @@ function ChannelFormEditor(props: ChannelFormEditorProps) {
327
365
 
328
366
  <ConfigSplitPaneFooter className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
329
367
  <div className="flex flex-wrap items-center gap-2">
330
- {props.actions.filter((action) => action.trigger === 'manual').map((action) => (
368
+ {actions.filter((action) => action.trigger === 'manual').map((action) => (
331
369
  <Button key={action.id} type="button" onClick={() => void handleManualAction(action)} disabled={disabled} variant="secondary">
332
370
  {runningActionId === action.id ? t('connecting') : action.title}
333
371
  </Button>
@@ -1,11 +1,12 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import type * as ReactQueryModule from '@tanstack/react-query';
4
- import { QrChannelAuthSection, WeixinChannelAuthSection } from '@/features/channels/components/config/weixin-channel-auth-section';
4
+ import { FeishuChannelAuthSection, QrChannelAuthSection, WeixinChannelAuthSection } from '@/features/channels/components/config/weixin-channel-auth-section';
5
5
 
6
6
  const mocks = vi.hoisted(() => ({
7
7
  startChannelAuthMutateAsync: vi.fn(),
8
8
  pollChannelAuthMutateAsync: vi.fn(),
9
+ connectChannelAuthMutateAsync: vi.fn(),
9
10
  invalidateQueries: vi.fn().mockResolvedValue(undefined)
10
11
  }));
11
12
 
@@ -34,6 +35,10 @@ vi.mock('@/features/channels/hooks/use-channel-auth', () => ({
34
35
  usePollChannelAuth: () => ({
35
36
  mutateAsync: mocks.pollChannelAuthMutateAsync,
36
37
  isPending: false
38
+ }),
39
+ useConnectChannelAuth: () => ({
40
+ mutateAsync: mocks.connectChannelAuthMutateAsync,
41
+ isPending: false
37
42
  })
38
43
  }));
39
44
 
@@ -41,6 +46,7 @@ describe('WeixinChannelAuthSection', () => {
41
46
  beforeEach(() => {
42
47
  mocks.startChannelAuthMutateAsync.mockReset();
43
48
  mocks.pollChannelAuthMutateAsync.mockReset();
49
+ mocks.connectChannelAuthMutateAsync.mockReset();
44
50
  mocks.invalidateQueries.mockClear();
45
51
  });
46
52
 
@@ -154,4 +160,42 @@ describe('WeixinChannelAuthSection', () => {
154
160
  });
155
161
  });
156
162
  });
163
+
164
+ it('connects an existing feishu agent with app credentials', async () => {
165
+ const user = userEvent.setup();
166
+ mocks.connectChannelAuthMutateAsync.mockResolvedValue({
167
+ channel: 'feishu',
168
+ status: 'authorized',
169
+ message: 'connected',
170
+ accountId: 'cli_existing',
171
+ notes: []
172
+ });
173
+
174
+ render(
175
+ <FeishuChannelAuthSection
176
+ channelConfig={{ enabled: false, domain: 'feishu' }}
177
+ formData={{ domain: 'feishu' }}
178
+ channelEnabled={false}
179
+ />
180
+ );
181
+
182
+ await user.click(screen.getByRole('button', { name: 'Connect existing agent' }));
183
+ await user.type(screen.getByLabelText('App ID'), 'cli_existing');
184
+ await user.type(screen.getByLabelText('App Secret'), 'secret-existing');
185
+ await user.click(screen.getByRole('button', { name: 'Verify and connect' }));
186
+
187
+ await waitFor(() => {
188
+ expect(mocks.connectChannelAuthMutateAsync).toHaveBeenCalledWith({
189
+ channel: 'feishu',
190
+ data: {
191
+ domain: 'feishu',
192
+ fields: {
193
+ appId: 'cli_existing',
194
+ appSecret: 'secret-existing'
195
+ }
196
+ }
197
+ });
198
+ });
199
+ expect(screen.getByRole('link', { name: 'Open Feishu agent list' }).getAttribute('href')).toBe('https://open.feishu.cn/app');
200
+ });
157
201
  });
@@ -4,7 +4,11 @@ import { toDataURL } from 'qrcode';
4
4
  import { ExternalLink, Loader2, MessageCircleMore, QrCode } from 'lucide-react';
5
5
  import { toast } from 'sonner';
6
6
  import { Button } from '@/shared/components/ui/button';
7
- import { usePollChannelAuth, useStartChannelAuth } from '@/features/channels/hooks/use-channel-auth';
7
+ import { Input } from '@/shared/components/ui/input';
8
+ import { Label } from '@/shared/components/ui/label';
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
11
+ import { useConnectChannelAuth, usePollChannelAuth, useStartChannelAuth } from '@/features/channels/hooks/use-channel-auth';
8
12
  import { formatDateTime, t } from '@/shared/lib/i18n';
9
13
  import { cn } from '@/shared/lib/utils';
10
14
  import type { ChannelAuthPollResult, ChannelAuthStartResult } from '@/shared/lib/api';
@@ -18,6 +22,8 @@ type QrChannelAuthSectionProps = {
18
22
  };
19
23
 
20
24
  type WeixinChannelAuthSectionProps = Omit<QrChannelAuthSectionProps, 'channelName'>;
25
+ type FeishuChannelAuthSectionProps = WeixinChannelAuthSectionProps;
26
+ type FeishuPlatform = 'feishu' | 'lark';
21
27
 
22
28
  type QrChannelAuthCopy = {
23
29
  title: string;
@@ -103,6 +109,15 @@ function resolveDomain(formData: Record<string, unknown>, channelConfig: Record<
103
109
  return configDomain || undefined;
104
110
  }
105
111
 
112
+ function resolveFeishuPlatform(formData: Record<string, unknown>, channelConfig: Record<string, unknown>): FeishuPlatform {
113
+ const domain = resolveDomain(formData, channelConfig);
114
+ return domain === 'lark' ? 'lark' : 'feishu';
115
+ }
116
+
117
+ function resolveFeishuDeveloperConsoleUrl(platform: FeishuPlatform): string {
118
+ return platform === 'lark' ? 'https://open.larksuite.com/app' : 'https://open.feishu.cn/app';
119
+ }
120
+
106
121
  function useQrDataUrl(channelName: string, qrCodeUrl: string | undefined) {
107
122
  return useQuery({
108
123
  queryKey: ['channel-qr', channelName, qrCodeUrl],
@@ -337,6 +352,109 @@ export function QrChannelAuthSection({
337
352
  );
338
353
  }
339
354
 
355
+ function ExistingFeishuAgentConnectPanel({
356
+ channelConfig,
357
+ disabled,
358
+ formData
359
+ }: {
360
+ channelConfig: Record<string, unknown>;
361
+ disabled?: boolean;
362
+ formData: Record<string, unknown>;
363
+ }) {
364
+ const queryClient = useQueryClient();
365
+ const connectChannelAuth = useConnectChannelAuth();
366
+ const [platform, setPlatform] = useState<FeishuPlatform>(() => resolveFeishuPlatform(formData, channelConfig));
367
+ const [appId, setAppId] = useState('');
368
+ const [appSecret, setAppSecret] = useState('');
369
+ const developerConsoleUrl = resolveFeishuDeveloperConsoleUrl(platform);
370
+ const canSubmit = Boolean(appId.trim() && appSecret.trim()) && !disabled && !connectChannelAuth.isPending;
371
+
372
+ const handleConnect = async () => {
373
+ try {
374
+ const result = await connectChannelAuth.mutateAsync({
375
+ channel: 'feishu',
376
+ data: {
377
+ domain: platform,
378
+ fields: {
379
+ appId: appId.trim(),
380
+ appSecret: appSecret.trim()
381
+ }
382
+ }
383
+ });
384
+ await queryClient.invalidateQueries({ queryKey: ['config'] });
385
+ await queryClient.invalidateQueries({ queryKey: ['config-meta'] });
386
+ toast.success(result.message || t('feishuExistingAgentConnectSuccess'));
387
+ setAppSecret('');
388
+ } catch (error) {
389
+ toast.error(`${t('error')}: ${error instanceof Error ? error.message : String(error)}`);
390
+ }
391
+ };
392
+
393
+ return (
394
+ <section className="rounded-2xl border border-primary/20 bg-white p-5">
395
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
396
+ <div className="max-w-xl space-y-3">
397
+ <div className="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700">
398
+ <MessageCircleMore className="h-3.5 w-3.5" />
399
+ {t('feishuExistingAgentTitle')}
400
+ </div>
401
+ <div>
402
+ <h4 className="text-base font-semibold text-gray-900">{t('feishuExistingAgentDescription')}</h4>
403
+ <p className="mt-1 text-sm text-gray-600">{t('feishuExistingAgentHint')}</p>
404
+ </div>
405
+ <a href={developerConsoleUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 text-sm text-primary transition-colors hover:text-primary-hover">
406
+ <ExternalLink className="h-4 w-4" />
407
+ {platform === 'lark' ? t('feishuExistingAgentOpenLarkList') : t('feishuExistingAgentOpenFeishuList')}
408
+ </a>
409
+ </div>
410
+ <div className="w-full max-w-sm space-y-4">
411
+ <div className="space-y-2">
412
+ <Label htmlFor="feishu-existing-platform">{t('feishuPlatform')}</Label>
413
+ <Select value={platform} onValueChange={(value) => setPlatform(value === 'lark' ? 'lark' : 'feishu')}>
414
+ <SelectTrigger id="feishu-existing-platform" className="rounded-xl">
415
+ <SelectValue />
416
+ </SelectTrigger>
417
+ <SelectContent>
418
+ <SelectItem value="feishu">Feishu</SelectItem>
419
+ <SelectItem value="lark">Lark</SelectItem>
420
+ </SelectContent>
421
+ </Select>
422
+ </div>
423
+ <div className="space-y-2">
424
+ <Label htmlFor="feishu-existing-app-id">App ID</Label>
425
+ <Input id="feishu-existing-app-id" value={appId} onChange={(event) => setAppId(event.target.value)} className="rounded-xl" />
426
+ </div>
427
+ <div className="space-y-2">
428
+ <Label htmlFor="feishu-existing-app-secret">App Secret</Label>
429
+ <Input id="feishu-existing-app-secret" type="password" value={appSecret} onChange={(event) => setAppSecret(event.target.value)} className="rounded-xl" />
430
+ </div>
431
+ <Button type="button" onClick={() => void handleConnect()} disabled={!canSubmit} className="w-full rounded-xl">
432
+ {connectChannelAuth.isPending ? t('feishuExistingAgentConnecting') : t('feishuExistingAgentConnect')}
433
+ </Button>
434
+ </div>
435
+ </div>
436
+ </section>
437
+ );
438
+ }
439
+
440
+ export function FeishuChannelAuthSection(props: FeishuChannelAuthSectionProps) {
441
+ const [mode, setMode] = useState<'create' | 'existing'>('create');
442
+ return (
443
+ <Tabs value={mode} onValueChange={(value) => setMode(value === 'existing' ? 'existing' : 'create')}>
444
+ <TabsList>
445
+ <TabsTrigger value="create">{t('feishuAuthCreateNew')}</TabsTrigger>
446
+ <TabsTrigger value="existing">{t('feishuAuthConnectExisting')}</TabsTrigger>
447
+ </TabsList>
448
+ <TabsContent value="create" className="mt-4">
449
+ <QrChannelAuthSection {...props} channelName="feishu" />
450
+ </TabsContent>
451
+ <TabsContent value="existing" className="mt-4">
452
+ <ExistingFeishuAgentConnectPanel {...props} />
453
+ </TabsContent>
454
+ </Tabs>
455
+ );
456
+ }
457
+
340
458
  export function WeixinChannelAuthSection(props: WeixinChannelAuthSectionProps) {
341
459
  return <QrChannelAuthSection {...props} channelName="weixin" />;
342
460
  }
@@ -1,5 +1,5 @@
1
1
  import { useMutation } from '@tanstack/react-query';
2
- import { pollChannelAuth, startChannelAuth } from '@/shared/lib/api';
2
+ import { connectChannelAuth, pollChannelAuth, startChannelAuth } from '@/shared/lib/api';
3
3
 
4
4
  export function useStartChannelAuth() {
5
5
  return useMutation({
@@ -14,3 +14,10 @@ export function usePollChannelAuth() {
14
14
  pollChannelAuth(channel, data as Parameters<typeof pollChannelAuth>[1])
15
15
  });
16
16
  }
17
+
18
+ export function useConnectChannelAuth() {
19
+ return useMutation({
20
+ mutationFn: ({ channel, data }: { channel: string; data: unknown }) =>
21
+ connectChannelAuth(channel, data as Parameters<typeof connectChannelAuth>[1])
22
+ });
23
+ }
@@ -1,5 +1,5 @@
1
1
  export { ChannelForm } from './components/config/channel-form';
2
2
  export { ChannelFormFieldsSection } from './components/channel-form-fields-section';
3
- export { QrChannelAuthSection, WeixinChannelAuthSection } from './components/config/weixin-channel-auth-section';
3
+ export { FeishuChannelAuthSection, QrChannelAuthSection, WeixinChannelAuthSection } from './components/config/weixin-channel-auth-section';
4
4
  export { ChannelsList } from './pages/channels-list-page';
5
5
  export { buildChannelFields, buildChannelFormDefinitions, type ChannelField, type ChannelFieldType, type ChannelFormBlock, type ChannelFormDefinition, type ChannelFormFieldSection, type ChannelOption } from './utils/channel-form-fields.utils';
@@ -281,7 +281,7 @@ describe("ChatConversationPanel", () => {
281
281
  expect(onBackToList).toHaveBeenCalledTimes(1);
282
282
  });
283
283
 
284
- it("opens the new draft session immediately when mobile welcome creates a session", async () => {
284
+ it("lets the session list owner route new mobile welcome drafts", async () => {
285
285
  const user = userEvent.setup();
286
286
 
287
287
  render(<ChatConversationPanel layoutMode="mobile" />);
@@ -291,7 +291,7 @@ describe("ChatConversationPanel", () => {
291
291
  );
292
292
 
293
293
  expect(mocks.createSession).toHaveBeenCalledWith("native");
294
- expect(mocks.goToChatRoot).toHaveBeenCalledTimes(1);
294
+ expect(mocks.goToChatRoot).not.toHaveBeenCalled();
295
295
  });
296
296
 
297
297
  it("shows the selected session project badge and more actions trigger", () => {
@@ -141,11 +141,7 @@ function ChatConversationAlerts() {
141
141
  );
142
142
  }
143
143
 
144
- function ChatConversationContent({
145
- layoutMode,
146
- }: {
147
- layoutMode: ChatConversationLayoutMode;
148
- }) {
144
+ function ChatConversationContent() {
149
145
  const presenter = usePresenter();
150
146
  const defaultSessionType = useChatInputStore(
151
147
  (state) => state.snapshot.defaultSessionType,
@@ -172,7 +168,6 @@ function ChatConversationContent({
172
168
  defaultSessionType,
173
169
  );
174
170
  presenter.chatSessionListManager.createSession(sessionType);
175
- if (layoutMode === "mobile") presenter.chatUiManager.goToChatRoot();
176
171
  };
177
172
  const selectDraftAgent = (agentId: string) => {
178
173
  presenter.chatSessionListManager.setSelectedAgentId(agentId);
@@ -398,7 +393,7 @@ export function ChatConversationPanel({
398
393
  onBackToList={onBackToList}
399
394
  />
400
395
  <ChatConversationAlerts />
401
- <ChatConversationContent layoutMode={layoutMode} />
396
+ <ChatConversationContent />
402
397
  <ChatInputBarContainer />
403
398
  </div>
404
399
 
@@ -179,6 +179,30 @@ describe('ChatSidebar create and list basics', () => {
179
179
  });
180
180
  });
181
181
 
182
+ it('does not animate the desktop create task button width during runtime option hydration', () => {
183
+ render(
184
+ <MemoryRouter>
185
+ <ChatSidebar />
186
+ </MemoryRouter>
187
+ );
188
+
189
+ const createButton = screen.getByRole('button', { name: 'New Task' });
190
+ const sessionTypeButton = screen.getByLabelText('Session Type');
191
+
192
+ expect(createButton.className).not.toContain('transition-all');
193
+ expect(sessionTypeButton.className).not.toContain('transition-all');
194
+ });
195
+
196
+ it('keeps the desktop brand row compact with symmetric vertical padding', () => {
197
+ render(
198
+ <MemoryRouter>
199
+ <ChatSidebar />
200
+ </MemoryRouter>
201
+ );
202
+
203
+ expect(screen.getByTestId('brand-header').parentElement?.className).toContain('py-2.5');
204
+ });
205
+
182
206
  it('shows setup required status for runtime session types that are not ready yet', () => {
183
207
  useChatInputStore.setState({
184
208
  snapshot: {
@@ -241,7 +265,7 @@ describe('ChatSidebar create and list basics', () => {
241
265
 
242
266
  expect(mocks.setQuery).toHaveBeenCalledWith('release notes');
243
267
  expect(mocks.createSession).toHaveBeenCalledWith('codex', undefined);
244
- expect(mocks.goToChatRoot).toHaveBeenCalledWith();
268
+ expect(mocks.goToChatRoot).not.toHaveBeenCalled();
245
269
  });
246
270
 
247
271
  it('keeps low-frequency utility choices behind nested selectors', () => {
@@ -292,7 +316,7 @@ describe('ChatSidebar create and list basics', () => {
292
316
  fireEvent.click(screen.getByRole('button', { name: 'New Task' }));
293
317
 
294
318
  expect(mocks.createSession).toHaveBeenCalledWith('native', undefined);
295
- expect(mocks.goToChatRoot).toHaveBeenCalledWith();
319
+ expect(mocks.goToChatRoot).not.toHaveBeenCalled();
296
320
  });
297
321
 
298
322
  it('shows a session type badge for non-native sessions in the list', () => {
@@ -601,7 +625,7 @@ describe('ChatSidebar project-first mode', () => {
601
625
  fireEvent.click(screen.getByText('Codex'));
602
626
 
603
627
  expect(mocks.createSession).toHaveBeenCalledWith('codex', '/tmp/project-mobile');
604
- expect(mocks.goToChatRoot).toHaveBeenCalledWith();
628
+ expect(mocks.goToChatRoot).not.toHaveBeenCalled();
605
629
  });
606
630
  });
607
631
 
@@ -30,7 +30,7 @@ import {
30
30
  ChatSidebarMobileToolbar,
31
31
  } from '@/features/chat/components/layout/chat-sidebar-toolbar';
32
32
  import { ChatSidebarUtilityMenu } from '@/features/chat/components/layout/chat-sidebar-utility-menu';
33
- import { isMacDesktopHost, isWindowsDesktopHost } from '@/platforms/desktop';
33
+ import { isWindowsDesktopHost } from '@/platforms/desktop';
34
34
 
35
35
  type DateGroup = {
36
36
  label: string;
@@ -256,7 +256,6 @@ export function ChatSidebar({
256
256
  );
257
257
  const createSessionAndOpenIfNeeded = (sessionType: string, projectRoot?: string | null) => {
258
258
  presenter.chatSessionListManager.createSession(sessionType, typeof projectRoot === "string" ? projectRoot : undefined);
259
- if (isMobileVariant) presenter.chatUiManager.goToChatRoot();
260
259
  };
261
260
 
262
261
  return (
@@ -269,7 +268,7 @@ export function ChatSidebar({
269
268
  )}
270
269
  >
271
270
  {!isMobileVariant && !isWindowsDesktopHost() ? (
272
- <div className={cn('px-5 pb-3', isMacDesktopHost() ? 'pt-1.5' : 'pt-5')}>
271
+ <div className="px-5 py-2.5">
273
272
  <BrandHeader
274
273
  className="flex min-w-0 items-center gap-2"
275
274
  suffix={<StatusBadge status={systemStatus.connectionStatus} />}