@nextclaw/ui 0.12.9 → 0.12.11

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 (245) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/assets/ChannelsList-SQ7Oxotv.js +8 -0
  3. package/dist/assets/DocBrowser-BCO2k6XD.js +1 -0
  4. package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-rDOjI3ga.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BUq3Wo8O.js} +1 -1
  6. package/dist/assets/{LogoBadge-ByNLYg65.js → LogoBadge-DP8Ye7wJ.js} +1 -1
  7. package/dist/assets/ModelConfig-C77Ae9ru.js +1 -0
  8. package/dist/assets/ProviderScopedModelInput-CEnK61uo.js +1 -0
  9. package/dist/assets/ProvidersList-BCupBayq.js +1 -0
  10. package/dist/assets/RuntimeConfig-Ad-CAcmy.js +1 -0
  11. package/dist/assets/SearchConfig-BfCz4wJ4.js +1 -0
  12. package/dist/assets/SecretsConfig-DjmBIhyy.js +3 -0
  13. package/dist/assets/{SessionsConfig-ChHQ7M5c.js → SessionsConfig-CvjxU40H.js} +2 -2
  14. package/dist/assets/{book-open-BdcxxoQu.js → book-open-BE8M56IM.js} +1 -1
  15. package/dist/assets/chat-page-JKC6ln-y.js +58 -0
  16. package/dist/assets/chat-session-display-YcRMrAMa.js +1 -0
  17. package/dist/assets/{chunk-JZWAC4HX-DK5HPmIK.js → chunk-JZWAC4HX-erTUn3b8.js} +1 -1
  18. package/dist/assets/client-CszWMVKi.js +7 -0
  19. package/dist/assets/config-split-page-BAGSzUR3.js +1 -0
  20. package/dist/assets/{createLucideIcon-BSeTgkZW.js → createLucideIcon-CCiTGX8L.js} +1 -1
  21. package/dist/assets/desktop-DfkLlkG2.js +1 -0
  22. package/dist/assets/desktop-update-config-BXeGlqHD.js +1 -0
  23. package/dist/assets/dialog-BghZFPch.js +5 -0
  24. package/dist/assets/{dist-6TrrnPCR.js → dist-Dd9cr-kz.js} +1 -1
  25. package/dist/assets/dist-ZwoAXs46.js +9 -0
  26. package/dist/assets/{download-BhDxnyvU.js → download-D7LOizcW.js} +1 -1
  27. package/dist/assets/es2015-CEAreese.js +41 -0
  28. package/dist/assets/{external-link-BgErLCNT.js → external-link-qsnCMhw1.js} +1 -1
  29. package/dist/assets/{hash-Bl7dr_UG.js → hash-0zjWsNl-.js} +1 -1
  30. package/dist/assets/{i18n-eDHeDY0n.js → i18n-DvzXOGQX.js} +1 -1
  31. package/dist/assets/index-DvVTC9FF.css +1 -0
  32. package/dist/assets/index-lr6rQUSd.js +2 -0
  33. package/dist/assets/key-round-BLe9D8ND.js +1 -0
  34. package/dist/assets/loader-circle-wj7kARHv.js +1 -0
  35. package/dist/assets/{logos-x89HbrZ4.js → logos-_v5b2SdG.js} +1 -1
  36. package/dist/assets/marketplace-page-CAAk1Khc.js +1 -0
  37. package/dist/assets/marketplace-page-CfCiq90S.js +49 -0
  38. package/dist/assets/mcp-marketplace-page-D0Pp9Hs-.js +40 -0
  39. package/dist/assets/play-o6NmwGTi.js +1 -0
  40. package/dist/assets/plus-I9pBS4Fl.js +1 -0
  41. package/dist/assets/{refresh-cw-C47QSEwg.js → refresh-cw-MNqgR3LZ.js} +1 -1
  42. package/dist/assets/remote-C9fXm4V5.js +1 -0
  43. package/dist/assets/{save-3S6-H3Xw.js → save-D4bObrmH.js} +1 -1
  44. package/dist/assets/search-DxmL3IWE.js +1 -0
  45. package/dist/assets/security-config-BUm6FFfl.js +1 -0
  46. package/dist/assets/select-BILPf7zs.js +1 -0
  47. package/dist/assets/setting-row-BATDgg4r.js +1 -0
  48. package/dist/assets/skeleton-COKMAnJy.js +1 -0
  49. package/dist/assets/{switch-BsLtHOH-.js → switch-CBOzecWS.js} +1 -1
  50. package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-Bx3cNhD-.js} +1 -1
  51. package/dist/assets/tag-chip-zUaDE2-H.js +1 -0
  52. package/dist/assets/{trash-2-G48scll7.js → trash-2-CQUgYyRn.js} +1 -1
  53. package/dist/assets/use-infinite-scroll-loader-B5V2Klve.js +1 -0
  54. package/dist/assets/useConfirmDialog-patAnl1g.js +1 -0
  55. package/dist/assets/{useMutation-CBWjE2uj.js → useMutation-__AYv-Pz.js} +1 -1
  56. package/dist/assets/x-BHUGQIUv.js +1 -0
  57. package/dist/index.html +22 -22
  58. package/dist/runtime-icons/claude.ico +0 -0
  59. package/dist/runtime-icons/codex-openai.svg +6 -0
  60. package/dist/runtime-icons/hermes-agent.png +0 -0
  61. package/module-structure.config.json +7 -0
  62. package/package.json +6 -6
  63. package/public/runtime-icons/claude.ico +0 -0
  64. package/public/runtime-icons/codex-openai.svg +6 -0
  65. package/public/runtime-icons/hermes-agent.png +0 -0
  66. package/src/api/chat-session-type.types.ts +7 -0
  67. package/src/api/config.ts +10 -0
  68. package/src/api/raw-client.test.ts +1 -1
  69. package/src/api/{raw-client.ts → raw-client.utils.ts} +2 -0
  70. package/src/api/runtime-control.types.ts +8 -0
  71. package/src/api/types.ts +48 -0
  72. package/src/app/components/app-manager-provider.tsx +20 -0
  73. package/src/app/managers/app.manager.ts +12 -0
  74. package/src/app.tsx +223 -59
  75. package/src/components/agents/agent-dialogs.tsx +499 -0
  76. package/src/components/agents/agents-page.test.tsx +238 -0
  77. package/src/components/agents/agents-page.tsx +435 -0
  78. package/src/components/chat/chat-conversation-panel.test.tsx +30 -0
  79. package/src/components/chat/chat-conversation-panel.tsx +83 -13
  80. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.test.ts +92 -0
  81. package/src/components/chat/chat-input/ncp-chat-input-availability.utils.ts +45 -0
  82. package/src/components/chat/chat-page-shell.tsx +19 -13
  83. package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
  84. package/src/components/chat/chat-session-type-option-item.tsx +68 -0
  85. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
  86. package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
  87. package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
  88. package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
  89. package/src/components/chat/containers/chat-input-bar.container.tsx +24 -12
  90. package/src/components/chat/{ChatSidebar.test.tsx → containers/chat-sidebar.test.tsx} +5 -4
  91. package/src/components/chat/{ChatSidebar.tsx → containers/chat-sidebar.tsx} +24 -72
  92. package/src/components/chat/hooks/use-chat-sidebar-session-label-editor.ts +49 -0
  93. package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
  94. package/src/components/chat/ncp/ncp-app-client-fetch.ts +3 -0
  95. package/src/components/chat/ncp/ncp-chat-input.manager.ts +13 -5
  96. package/src/components/chat/ncp/ncp-chat-page.tsx +23 -2
  97. package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
  98. package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
  99. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
  100. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +48 -4
  101. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +43 -5
  102. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +51 -1
  103. package/src/components/chat/stores/chat-input.store.ts +2 -1
  104. package/src/components/chat/stores/chat-thread.store.ts +3 -1
  105. package/src/components/chat/useChatSessionTypeState.ts +10 -1
  106. package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
  107. package/src/components/common/BrandHeader.tsx +3 -1
  108. package/src/components/common/session-context-icon.tsx +15 -2
  109. package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
  110. package/src/components/config/ChannelForm.test.tsx +89 -3
  111. package/src/components/config/ChannelForm.tsx +157 -188
  112. package/src/components/config/ChannelsList.test.tsx +163 -119
  113. package/src/components/config/ChannelsList.tsx +90 -101
  114. package/src/components/config/ProviderForm.tsx +108 -146
  115. package/src/components/config/ProvidersList.tsx +100 -123
  116. package/src/components/config/SearchConfig.tsx +423 -393
  117. package/src/components/config/channel-form-fields-section.tsx +70 -37
  118. package/src/components/config/config-split-page.tsx +109 -0
  119. package/src/components/config/desktop-update-config.test.tsx +10 -4
  120. package/src/components/config/desktop-update-config.tsx +5 -3
  121. package/src/components/config/provider-enabled-field.tsx +17 -10
  122. package/src/components/config/runtime-control-card.test.tsx +136 -158
  123. package/src/components/config/runtime-control-card.tsx +43 -68
  124. package/src/components/config/runtime-presence-card.test.tsx +10 -14
  125. package/src/components/config/runtime-presence-card.tsx +97 -81
  126. package/src/components/layout/AppLayout.tsx +25 -37
  127. package/src/components/layout/Sidebar.tsx +4 -4
  128. package/src/components/layout/app-layout.test.tsx +46 -14
  129. package/src/components/layout/runtime-status-entry.test.tsx +101 -0
  130. package/src/components/layout/runtime-status-entry.tsx +95 -0
  131. package/src/components/layout/sidebar.layout.test.tsx +11 -5
  132. package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
  133. package/src/components/marketplace/marketplace-list-card.tsx +288 -0
  134. package/src/components/marketplace/marketplace-page-data.ts +129 -0
  135. package/src/components/marketplace/marketplace-page.test.tsx +339 -0
  136. package/src/components/marketplace/marketplace-page.tsx +596 -0
  137. package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
  138. package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
  139. package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
  140. package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
  141. package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
  142. package/src/components/ui/notice-card.tsx +129 -0
  143. package/src/components/ui/setting-row.tsx +51 -0
  144. package/src/components/ui/tag-chip.tsx +39 -0
  145. package/src/components/ui/textarea.tsx +19 -0
  146. package/src/features/account/components/account-panel.tsx +255 -0
  147. package/src/features/account/index.ts +6 -0
  148. package/src/{account → features/account}/managers/account.manager.ts +6 -5
  149. package/src/features/remote/components/remote-access-page.test.tsx +104 -0
  150. package/src/features/remote/components/remote-access-page.tsx +250 -0
  151. package/src/{hooks/useRemoteAccess.ts → features/remote/hooks/use-remote-access.ts} +1 -1
  152. package/src/features/remote/index.ts +27 -0
  153. package/src/{remote → features/remote}/managers/remote-access.manager.ts +3 -4
  154. package/src/{remote → features/remote/services}/remote-access-feedback.service.test.ts +1 -1
  155. package/src/features/system-status/hooks/use-system-status.ts +104 -0
  156. package/src/features/system-status/index.ts +12 -0
  157. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +126 -0
  158. package/src/features/system-status/managers/system-status.manager.test.ts +142 -0
  159. package/src/features/system-status/managers/system-status.manager.ts +511 -0
  160. package/src/features/system-status/stores/system-status.store.ts +32 -0
  161. package/src/features/system-status/types/system-status.types.ts +73 -0
  162. package/src/features/system-status/utils/system-status.utils.test.ts +132 -0
  163. package/src/features/system-status/utils/system-status.utils.ts +202 -0
  164. package/src/hooks/use-realtime-query-bridge.ts +34 -18
  165. package/src/hooks/useConfig.ts +2 -1
  166. package/src/index.css +24 -0
  167. package/src/lib/app-resource-uri.test.ts +20 -0
  168. package/src/lib/app-resource-uri.ts +29 -0
  169. package/src/lib/i18n.chat.ts +8 -0
  170. package/src/lib/i18n.remote.ts +1 -1
  171. package/src/lib/i18n.runtime-control.ts +31 -0
  172. package/src/lib/i18n.ts +5 -8
  173. package/src/lib/session-context.utils.test.ts +71 -0
  174. package/src/lib/session-context.utils.ts +28 -3
  175. package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
  176. package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
  177. package/src/platforms/desktop/index.ts +20 -0
  178. package/src/{desktop → platforms/desktop}/managers/desktop-presence.manager.ts +2 -2
  179. package/src/{desktop → platforms/desktop}/managers/desktop-update.manager.ts +2 -2
  180. package/src/{desktop → platforms/desktop}/stores/desktop-presence.store.ts +1 -1
  181. package/src/{desktop → platforms/desktop}/stores/desktop-update.store.ts +1 -1
  182. package/src/stores/ui.store.ts +0 -9
  183. package/src/transport/{app-client.ts → app-client.service.ts} +9 -9
  184. package/src/transport/app-client.test.ts +9 -5
  185. package/src/transport/index.ts +1 -1
  186. package/src/transport/{local.transport.ts → local-transport.service.ts} +14 -12
  187. package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
  188. package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
  189. package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
  190. package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
  191. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
  192. package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
  193. package/dist/assets/ProviderScopedModelInput-Da7khnBA.js +0 -1
  194. package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
  195. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
  196. package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
  197. package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
  198. package/dist/assets/SecretsConfig-D281Rotl.js +0 -3
  199. package/dist/assets/app-query-client-VnFElj4E.js +0 -1
  200. package/dist/assets/chat-page-Doe0yTtB.js +0 -58
  201. package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
  202. package/dist/assets/client-_i4MU2bB.js +0 -7
  203. package/dist/assets/config-DtIQwrHF.js +0 -1
  204. package/dist/assets/config-layout-CHs0mAaR.js +0 -1
  205. package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
  206. package/dist/assets/dist-ccBFUi-o.js +0 -9
  207. package/dist/assets/index-CF9xve0E.js +0 -6
  208. package/dist/assets/index-FgA52VBt.css +0 -1
  209. package/dist/assets/infiniteQueryBehavior-ZDS92Qpp.js +0 -1
  210. package/dist/assets/loader-circle-ACM1s51e.js +0 -1
  211. package/dist/assets/page-layout-vZnghcFy.js +0 -1
  212. package/dist/assets/play-CFUwCA2E.js +0 -1
  213. package/dist/assets/plus-rYsv72JG.js +0 -1
  214. package/dist/assets/popover-Bg1VoTZ6.js +0 -1
  215. package/dist/assets/refresh-ccw-DT98i__E.js +0 -1
  216. package/dist/assets/rotate-cw-JtFzpNn6.js +0 -1
  217. package/dist/assets/search-3kFR_zh9.js +0 -1
  218. package/dist/assets/security-config-BWaiARNk.js +0 -1
  219. package/dist/assets/select-DJ2MUjBB.js +0 -41
  220. package/dist/assets/skeleton-ByQepn0M.js +0 -1
  221. package/dist/assets/status-dot-vbanNPFU.js +0 -1
  222. package/dist/assets/use-infinite-scroll-loader-DkNhD-42.js +0 -1
  223. package/dist/assets/useConfirmDialog-BkvTN-vd.js +0 -1
  224. package/dist/assets/x-ByDbItbq.js +0 -1
  225. package/src/account/components/account-panel.tsx +0 -135
  226. package/src/components/agents/AgentDialogs.tsx +0 -400
  227. package/src/components/agents/AgentsPage.test.tsx +0 -217
  228. package/src/components/agents/AgentsPage.tsx +0 -352
  229. package/src/components/config/config-layout.ts +0 -10
  230. package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
  231. package/src/components/marketplace/MarketplacePage.tsx +0 -827
  232. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
  233. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
  234. package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
  235. package/src/components/remote/RemoteAccessPage.tsx +0 -144
  236. package/src/hooks/use-runtime-control.ts +0 -24
  237. package/src/presenter/app-presenter-context.tsx +0 -20
  238. package/src/presenter/app.presenter.ts +0 -12
  239. package/src/runtime-control/runtime-control.manager.ts +0 -118
  240. /package/dist/assets/{config-hints-BhTmc9P1.js → config-hints-DSQQbeOA.js} +0 -0
  241. /package/src/{account → features/account}/stores/account.store.ts +0 -0
  242. /package/src/{remote → features/remote/services}/remote-access-feedback.service.ts +0 -0
  243. /package/src/{remote/remote-access.query.ts → features/remote/services/remote-access-query.service.ts} +0 -0
  244. /package/src/{remote → features/remote}/stores/remote-access.store.ts +0 -0
  245. /package/src/{desktop → platforms/desktop/types}/desktop-update.types.ts +0 -0
@@ -1,229 +1,203 @@
1
- import { useEffect, useMemo, useState } from 'react';
2
- import { ExternalLink, KeyRound, Search as SearchIcon } from 'lucide-react';
3
- import { PageHeader, PageLayout } from '@/components/layout/page-layout';
4
- import { Button } from '@/components/ui/button';
5
- import { Input } from '@/components/ui/input';
6
- import { Label } from '@/components/ui/label';
7
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
- import { useConfig, useConfigMeta, useUpdateSearch } from '@/hooks/useConfig';
9
- import { t } from '@/lib/i18n';
10
- import { cn } from '@/lib/utils';
11
- import { CONFIG_DETAIL_CARD_CLASS, CONFIG_SIDEBAR_CARD_CLASS, CONFIG_SPLIT_GRID_CLASS } from './config-layout';
12
- import type { SearchConfigUpdate, SearchProviderName, TavilySearchDepthValue } from '@/api/types';
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { ExternalLink, KeyRound, Search as SearchIcon } from "lucide-react";
3
+ import { PageHeader, PageLayout } from "@/components/layout/page-layout";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import { useConfig, useConfigMeta, useUpdateSearch } from "@/hooks/useConfig";
15
+ import { t } from "@/lib/i18n";
16
+ import type {
17
+ SearchConfigUpdate,
18
+ SearchProviderName,
19
+ TavilySearchDepthValue,
20
+ } from "@/api/types";
21
+ import {
22
+ ConfigSelectionCard,
23
+ ConfigSplitDetailPane,
24
+ ConfigSplitEmptyPane,
25
+ ConfigSplitPage,
26
+ ConfigSplitPaneBody,
27
+ ConfigSplitPaneFooter,
28
+ ConfigSplitPaneHeader,
29
+ ConfigSplitSidebar,
30
+ } from "./config-split-page";
13
31
 
14
32
  const FRESHNESS_OPTIONS = [
15
- { value: 'noLimit', label: 'searchFreshnessNoLimit' },
16
- { value: 'oneDay', label: 'searchFreshnessOneDay' },
17
- { value: 'oneWeek', label: 'searchFreshnessOneWeek' },
18
- { value: 'oneMonth', label: 'searchFreshnessOneMonth' },
19
- { value: 'oneYear', label: 'searchFreshnessOneYear' }
33
+ { value: "noLimit", label: "searchFreshnessNoLimit" },
34
+ { value: "oneDay", label: "searchFreshnessOneDay" },
35
+ { value: "oneWeek", label: "searchFreshnessOneWeek" },
36
+ { value: "oneMonth", label: "searchFreshnessOneMonth" },
37
+ { value: "oneYear", label: "searchFreshnessOneYear" },
20
38
  ] as const;
21
39
 
22
40
  const SEARCH_DEPTH_OPTIONS = [
23
- { value: 'basic', label: 'searchDepthBasic' },
24
- { value: 'advanced', label: 'searchDepthAdvanced' }
41
+ { value: "basic", label: "searchDepthBasic" },
42
+ { value: "advanced", label: "searchDepthAdvanced" },
25
43
  ] as const;
26
44
 
27
45
  const SEARCH_PROVIDER_DESCRIPTION_KEYS: Record<SearchProviderName, string> = {
28
- bocha: 'searchProviderBochaDescription',
29
- tavily: 'searchProviderTavilyDescription',
30
- brave: 'searchProviderBraveDescription'
46
+ bocha: "searchProviderBochaDescription",
47
+ tavily: "searchProviderTavilyDescription",
48
+ brave: "searchProviderBraveDescription",
31
49
  };
32
50
 
33
- type BochaProviderFieldsProps = {
34
- apiKey: string;
35
- apiKeyMasked?: string;
36
- baseUrl: string;
37
- summary: boolean;
38
- freshness: string;
39
- docsUrl?: string;
40
- onApiKeyChange: (value: string) => void;
41
- onBaseUrlChange: (value: string) => void;
42
- onSummaryChange: (value: boolean) => void;
43
- onFreshnessChange: (value: string) => void;
51
+ type SearchDraft = {
52
+ activeProvider: SearchProviderName;
53
+ enabledProviders: SearchProviderName[];
54
+ maxResults: string;
55
+ providers: {
56
+ bocha: {
57
+ apiKey: string;
58
+ baseUrl: string;
59
+ summary: boolean;
60
+ freshness: string;
61
+ };
62
+ tavily: {
63
+ apiKey: string;
64
+ baseUrl: string;
65
+ searchDepth: TavilySearchDepthValue;
66
+ includeAnswer: boolean;
67
+ };
68
+ brave: {
69
+ apiKey: string;
70
+ baseUrl: string;
71
+ };
72
+ };
44
73
  };
45
74
 
46
- function BochaProviderFields({
47
- apiKey,
48
- apiKeyMasked,
49
- baseUrl,
50
- summary,
51
- freshness,
52
- docsUrl,
53
- onApiKeyChange,
54
- onBaseUrlChange,
55
- onSummaryChange,
56
- onFreshnessChange
57
- }: BochaProviderFieldsProps) {
75
+ function buildSearchDraft(
76
+ search: NonNullable<ReturnType<typeof useConfig>["data"]>["search"],
77
+ ): SearchDraft {
78
+ return {
79
+ activeProvider: search.provider,
80
+ enabledProviders: search.enabledProviders,
81
+ maxResults: String(search.defaults.maxResults),
82
+ providers: {
83
+ bocha: {
84
+ apiKey: "",
85
+ baseUrl: search.providers.bocha.baseUrl,
86
+ summary: Boolean(search.providers.bocha.summary),
87
+ freshness: search.providers.bocha.freshness ?? "noLimit",
88
+ },
89
+ tavily: {
90
+ apiKey: "",
91
+ baseUrl: search.providers.tavily.baseUrl,
92
+ searchDepth: search.providers.tavily.searchDepth ?? "basic",
93
+ includeAnswer: Boolean(search.providers.tavily.includeAnswer),
94
+ },
95
+ brave: {
96
+ apiKey: "",
97
+ baseUrl: search.providers.brave.baseUrl,
98
+ },
99
+ },
100
+ };
101
+ }
102
+
103
+ function buildSearchPayload(draft: SearchDraft): SearchConfigUpdate {
104
+ return {
105
+ provider: draft.activeProvider,
106
+ enabledProviders: draft.enabledProviders,
107
+ defaults: { maxResults: Number(draft.maxResults) || 10 },
108
+ providers: {
109
+ bocha: {
110
+ apiKey: draft.providers.bocha.apiKey || undefined,
111
+ baseUrl: draft.providers.bocha.baseUrl,
112
+ summary: draft.providers.bocha.summary,
113
+ freshness: draft.providers.bocha.freshness,
114
+ },
115
+ tavily: {
116
+ apiKey: draft.providers.tavily.apiKey || undefined,
117
+ baseUrl: draft.providers.tavily.baseUrl,
118
+ searchDepth: draft.providers.tavily.searchDepth,
119
+ includeAnswer: draft.providers.tavily.includeAnswer,
120
+ },
121
+ brave: {
122
+ apiKey: draft.providers.brave.apiKey || undefined,
123
+ baseUrl: draft.providers.brave.baseUrl,
124
+ },
125
+ },
126
+ };
127
+ }
128
+
129
+ function SearchTextField({
130
+ label,
131
+ value,
132
+ onChange,
133
+ placeholder,
134
+ type = "text",
135
+ inputMode,
136
+ }: {
137
+ label: string;
138
+ value: string;
139
+ onChange: (value: string) => void;
140
+ placeholder?: string;
141
+ type?: string;
142
+ inputMode?: "numeric";
143
+ }) {
58
144
  return (
59
- <div className="space-y-4">
60
- <div className="space-y-2">
61
- <Label>{t('apiKey')}</Label>
62
- <Input
63
- type="password"
64
- value={apiKey}
65
- onChange={(event) => onApiKeyChange(event.target.value)}
66
- placeholder={apiKeyMasked || t('enterApiKey')}
67
- className="rounded-xl"
68
- />
69
- </div>
70
- <div className="space-y-2">
71
- <Label>{t('searchProviderBaseUrl')}</Label>
72
- <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
73
- </div>
74
- <div className="grid gap-4 md:grid-cols-2">
75
- <div className="space-y-2">
76
- <Label>{t('searchProviderSummary')}</Label>
77
- <Select value={summary ? 'true' : 'false'} onValueChange={(value) => onSummaryChange(value === 'true')}>
78
- <SelectTrigger className="rounded-xl">
79
- <SelectValue />
80
- </SelectTrigger>
81
- <SelectContent>
82
- <SelectItem value="true">{t('enabled')}</SelectItem>
83
- <SelectItem value="false">{t('disabled')}</SelectItem>
84
- </SelectContent>
85
- </Select>
86
- </div>
87
- <div className="space-y-2">
88
- <Label>{t('searchProviderFreshness')}</Label>
89
- <Select value={freshness} onValueChange={onFreshnessChange}>
90
- <SelectTrigger className="rounded-xl">
91
- <SelectValue />
92
- </SelectTrigger>
93
- <SelectContent>
94
- {FRESHNESS_OPTIONS.map((option) => (
95
- <SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
96
- ))}
97
- </SelectContent>
98
- </Select>
99
- </div>
100
- </div>
101
- <div className="space-y-2">
102
- <a href={docsUrl ?? 'https://open.bocha.cn'} target="_blank" rel="noreferrer">
103
- <Button type="button" variant="outline" className="rounded-xl">
104
- <ExternalLink className="mr-2 h-4 w-4" />
105
- {t('searchProviderOpenDocs')}
106
- </Button>
107
- </a>
108
- </div>
145
+ <div className="space-y-2">
146
+ <Label>{label}</Label>
147
+ <Input
148
+ type={type}
149
+ value={value}
150
+ onChange={(event) => onChange(event.target.value)}
151
+ placeholder={placeholder}
152
+ inputMode={inputMode}
153
+ className="rounded-xl"
154
+ />
109
155
  </div>
110
156
  );
111
157
  }
112
158
 
113
- type TavilyProviderFieldsProps = {
114
- apiKey: string;
115
- apiKeyMasked?: string;
116
- baseUrl: string;
117
- searchDepth: TavilySearchDepthValue;
118
- includeAnswer: boolean;
119
- docsUrl?: string;
120
- onApiKeyChange: (value: string) => void;
121
- onBaseUrlChange: (value: string) => void;
122
- onSearchDepthChange: (value: TavilySearchDepthValue) => void;
123
- onIncludeAnswerChange: (value: boolean) => void;
124
- };
125
-
126
- function TavilyProviderFields({
127
- apiKey,
128
- apiKeyMasked,
129
- baseUrl,
130
- searchDepth,
131
- includeAnswer,
132
- docsUrl,
133
- onApiKeyChange,
134
- onBaseUrlChange,
135
- onSearchDepthChange,
136
- onIncludeAnswerChange
137
- }: TavilyProviderFieldsProps) {
159
+ function SearchSelectField({
160
+ label,
161
+ value,
162
+ options,
163
+ onChange,
164
+ }: {
165
+ label: string;
166
+ value: string;
167
+ options: ReadonlyArray<{ value: string; label: string }>;
168
+ onChange: (value: string) => void;
169
+ }) {
138
170
  return (
139
- <div className="space-y-4">
140
- <div className="space-y-2">
141
- <Label>{t('apiKey')}</Label>
142
- <Input
143
- type="password"
144
- value={apiKey}
145
- onChange={(event) => onApiKeyChange(event.target.value)}
146
- placeholder={apiKeyMasked || t('enterApiKey')}
147
- className="rounded-xl"
148
- />
149
- </div>
150
- <div className="space-y-2">
151
- <Label>{t('searchProviderBaseUrl')}</Label>
152
- <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
153
- </div>
154
- <div className="grid gap-4 md:grid-cols-2">
155
- <div className="space-y-2">
156
- <Label>{t('searchProviderSearchDepth')}</Label>
157
- <Select value={searchDepth} onValueChange={(value) => onSearchDepthChange(value as TavilySearchDepthValue)}>
158
- <SelectTrigger className="rounded-xl">
159
- <SelectValue />
160
- </SelectTrigger>
161
- <SelectContent>
162
- {SEARCH_DEPTH_OPTIONS.map((option) => (
163
- <SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
164
- ))}
165
- </SelectContent>
166
- </Select>
167
- </div>
168
- <div className="space-y-2">
169
- <Label>{t('searchProviderIncludeAnswer')}</Label>
170
- <Select value={includeAnswer ? 'true' : 'false'} onValueChange={(value) => onIncludeAnswerChange(value === 'true')}>
171
- <SelectTrigger className="rounded-xl">
172
- <SelectValue />
173
- </SelectTrigger>
174
- <SelectContent>
175
- <SelectItem value="true">{t('enabled')}</SelectItem>
176
- <SelectItem value="false">{t('disabled')}</SelectItem>
177
- </SelectContent>
178
- </Select>
179
- </div>
180
- </div>
181
- {docsUrl ? (
182
- <div className="space-y-2">
183
- <a href={docsUrl} target="_blank" rel="noreferrer">
184
- <Button type="button" variant="outline" className="rounded-xl">
185
- <ExternalLink className="mr-2 h-4 w-4" />
186
- {t('searchProviderOpenDocs')}
187
- </Button>
188
- </a>
189
- </div>
190
- ) : null}
171
+ <div className="space-y-2">
172
+ <Label>{label}</Label>
173
+ <Select value={value} onValueChange={onChange}>
174
+ <SelectTrigger className="rounded-xl">
175
+ <SelectValue />
176
+ </SelectTrigger>
177
+ <SelectContent>
178
+ {options.map((option) => (
179
+ <SelectItem key={option.value} value={option.value}>
180
+ {t(option.label)}
181
+ </SelectItem>
182
+ ))}
183
+ </SelectContent>
184
+ </Select>
191
185
  </div>
192
186
  );
193
187
  }
194
188
 
195
- type BraveProviderFieldsProps = {
196
- apiKey: string;
197
- apiKeyMasked?: string;
198
- baseUrl: string;
199
- onApiKeyChange: (value: string) => void;
200
- onBaseUrlChange: (value: string) => void;
201
- };
189
+ function SearchDocsButton({ docsUrl }: { docsUrl?: string }) {
190
+ if (!docsUrl) {
191
+ return null;
192
+ }
202
193
 
203
- function BraveProviderFields({
204
- apiKey,
205
- apiKeyMasked,
206
- baseUrl,
207
- onApiKeyChange,
208
- onBaseUrlChange
209
- }: BraveProviderFieldsProps) {
210
194
  return (
211
- <div className="space-y-4">
212
- <div className="space-y-2">
213
- <Label>{t('apiKey')}</Label>
214
- <Input
215
- type="password"
216
- value={apiKey}
217
- onChange={(event) => onApiKeyChange(event.target.value)}
218
- placeholder={apiKeyMasked || t('enterApiKey')}
219
- className="rounded-xl"
220
- />
221
- </div>
222
- <div className="space-y-2">
223
- <Label>{t('searchProviderBaseUrl')}</Label>
224
- <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
225
- </div>
226
- </div>
195
+ <a href={docsUrl} target="_blank" rel="noreferrer">
196
+ <Button type="button" variant="outline" className="rounded-xl">
197
+ <ExternalLink className="mr-2 h-4 w-4" />
198
+ {t("searchProviderOpenDocs")}
199
+ </Button>
200
+ </a>
227
201
  );
228
202
  }
229
203
 
@@ -233,251 +207,307 @@ export function SearchConfig() {
233
207
  const updateSearch = useUpdateSearch();
234
208
  const providers = useMemo(() => meta?.search ?? [], [meta]);
235
209
  const search = config?.search;
236
-
237
- const [selectedProvider, setSelectedProvider] = useState<SearchProviderName>('bocha');
238
- const [activeProvider, setActiveProvider] = useState<SearchProviderName>('bocha');
239
- const [enabledProviders, setEnabledProviders] = useState<SearchProviderName[]>(['bocha']);
240
- const [maxResults, setMaxResults] = useState('10');
241
- const [bochaApiKey, setBochaApiKey] = useState('');
242
- const [bochaBaseUrl, setBochaBaseUrl] = useState('https://api.bocha.cn/v1/web-search');
243
- const [bochaSummary, setBochaSummary] = useState(true);
244
- const [bochaFreshness, setBochaFreshness] = useState('noLimit');
245
- const [tavilyApiKey, setTavilyApiKey] = useState('');
246
- const [tavilyBaseUrl, setTavilyBaseUrl] = useState('https://api.tavily.com/search');
247
- const [tavilySearchDepth, setTavilySearchDepth] = useState<TavilySearchDepthValue>('basic');
248
- const [tavilyIncludeAnswer, setTavilyIncludeAnswer] = useState(false);
249
- const [braveApiKey, setBraveApiKey] = useState('');
250
- const [braveBaseUrl, setBraveBaseUrl] = useState('https://api.search.brave.com/res/v1/web/search');
210
+ const [selectedProvider, setSelectedProvider] =
211
+ useState<SearchProviderName>("bocha");
212
+ const [draft, setDraft] = useState<SearchDraft | null>(null);
251
213
 
252
214
  useEffect(() => {
253
215
  if (!search) {
254
216
  return;
255
217
  }
256
218
  setSelectedProvider(search.provider);
257
- setActiveProvider(search.provider);
258
- setEnabledProviders(search.enabledProviders);
259
- setMaxResults(String(search.defaults.maxResults));
260
- setBochaBaseUrl(search.providers.bocha.baseUrl);
261
- setBochaSummary(Boolean(search.providers.bocha.summary));
262
- setBochaFreshness(search.providers.bocha.freshness ?? 'noLimit');
263
- setTavilyBaseUrl(search.providers.tavily.baseUrl);
264
- setTavilySearchDepth(search.providers.tavily.searchDepth ?? 'basic');
265
- setTavilyIncludeAnswer(Boolean(search.providers.tavily.includeAnswer));
266
- setBraveBaseUrl(search.providers.brave.baseUrl);
219
+ setDraft(buildSearchDraft(search));
267
220
  }, [search]);
268
221
 
269
- const selectedMeta = useMemo(
270
- () => providers.find((provider) => provider.name === selectedProvider),
271
- [providers, selectedProvider]
272
- );
273
- const selectedView = search?.providers[selectedProvider];
274
- const selectedEnabled = enabledProviders.includes(selectedProvider);
275
- const selectedDocsUrl = selectedView?.docsUrl ?? selectedMeta?.docsUrl;
276
- const activationButtonLabel = selectedEnabled
277
- ? t('searchProviderDeactivate')
278
- : t('searchProviderActivate');
222
+ const updateProviderDraft = <T extends SearchProviderName>(
223
+ provider: T,
224
+ patch: Partial<SearchDraft["providers"][T]>,
225
+ ) => {
226
+ setDraft((prev) =>
227
+ prev
228
+ ? {
229
+ ...prev,
230
+ providers: {
231
+ ...prev.providers,
232
+ [provider]: {
233
+ ...prev.providers[provider],
234
+ ...patch,
235
+ },
236
+ },
237
+ }
238
+ : prev,
239
+ );
240
+ };
279
241
 
280
- const buildSearchPayload = (
281
- nextEnabledProviders: SearchProviderName[] = enabledProviders,
282
- nextActiveProvider: SearchProviderName = activeProvider
283
- ): SearchConfigUpdate => ({
284
- provider: nextActiveProvider,
285
- enabledProviders: nextEnabledProviders,
286
- defaults: {
287
- maxResults: Number(maxResults) || 10
288
- },
289
- providers: {
290
- bocha: {
291
- apiKey: bochaApiKey || undefined,
292
- baseUrl: bochaBaseUrl,
293
- summary: bochaSummary,
294
- freshness: bochaFreshness
295
- },
296
- tavily: {
297
- apiKey: tavilyApiKey || undefined,
298
- baseUrl: tavilyBaseUrl,
299
- searchDepth: tavilySearchDepth,
300
- includeAnswer: tavilyIncludeAnswer
301
- },
302
- brave: {
303
- apiKey: braveApiKey || undefined,
304
- baseUrl: braveBaseUrl
305
- }
306
- }
307
- });
242
+ if (!search || providers.length === 0 || !draft) {
243
+ return <div className="p-8">{t("loading")}</div>;
244
+ }
245
+
246
+ const selectedMeta = providers.find((provider) => provider.name === selectedProvider);
247
+ const selectedView = search.providers[selectedProvider];
248
+ const selectedEnabled = draft.enabledProviders.includes(selectedProvider);
249
+ const selectedDocsUrl = selectedView?.docsUrl ?? selectedMeta?.docsUrl;
308
250
 
309
251
  const handleToggleEnabled = () => {
310
- const nextEnabledProviders = selectedEnabled
311
- ? enabledProviders.filter((provider) => provider !== selectedProvider)
312
- : [...enabledProviders, selectedProvider];
313
- setEnabledProviders(nextEnabledProviders);
314
- updateSearch.mutate({
315
- data: buildSearchPayload(nextEnabledProviders)
316
- });
252
+ const enabledProviders = selectedEnabled
253
+ ? draft.enabledProviders.filter((provider) => provider !== selectedProvider)
254
+ : [...draft.enabledProviders, selectedProvider];
255
+ const nextDraft = { ...draft, enabledProviders };
256
+ setDraft(nextDraft);
257
+ updateSearch.mutate({ data: buildSearchPayload(nextDraft) });
317
258
  };
318
259
 
319
- const handleActiveProviderChange = (value: string) => {
320
- setActiveProvider(value as SearchProviderName);
321
- };
260
+ const renderProviderFields = () => {
261
+ if (selectedProvider === "bocha") {
262
+ const bocha = draft.providers.bocha;
263
+ return (
264
+ <>
265
+ <SearchTextField
266
+ label={t("apiKey")}
267
+ value={bocha.apiKey}
268
+ onChange={(apiKey) => updateProviderDraft("bocha", { apiKey })}
269
+ placeholder={search.providers.bocha.apiKeyMasked || t("enterApiKey")}
270
+ type="password"
271
+ />
272
+ <SearchTextField
273
+ label={t("searchProviderBaseUrl")}
274
+ value={bocha.baseUrl}
275
+ onChange={(baseUrl) => updateProviderDraft("bocha", { baseUrl })}
276
+ />
277
+ <div className="grid gap-4 md:grid-cols-2">
278
+ <SearchSelectField
279
+ label={t("searchProviderSummary")}
280
+ value={bocha.summary ? "true" : "false"}
281
+ options={[
282
+ { value: "true", label: "enabled" },
283
+ { value: "false", label: "disabled" },
284
+ ]}
285
+ onChange={(value) =>
286
+ updateProviderDraft("bocha", { summary: value === "true" })
287
+ }
288
+ />
289
+ <SearchSelectField
290
+ label={t("searchProviderFreshness")}
291
+ value={bocha.freshness}
292
+ options={FRESHNESS_OPTIONS}
293
+ onChange={(freshness) =>
294
+ updateProviderDraft("bocha", { freshness })
295
+ }
296
+ />
297
+ </div>
298
+ <SearchDocsButton docsUrl={selectedDocsUrl ?? "https://open.bocha.cn"} />
299
+ </>
300
+ );
301
+ }
322
302
 
323
- const handleSubmit = (event: React.FormEvent) => {
324
- event.preventDefault();
325
- updateSearch.mutate({
326
- data: buildSearchPayload()
327
- });
328
- };
303
+ if (selectedProvider === "tavily") {
304
+ const tavily = draft.providers.tavily;
305
+ return (
306
+ <>
307
+ <SearchTextField
308
+ label={t("apiKey")}
309
+ value={tavily.apiKey}
310
+ onChange={(apiKey) => updateProviderDraft("tavily", { apiKey })}
311
+ placeholder={search.providers.tavily.apiKeyMasked || t("enterApiKey")}
312
+ type="password"
313
+ />
314
+ <SearchTextField
315
+ label={t("searchProviderBaseUrl")}
316
+ value={tavily.baseUrl}
317
+ onChange={(baseUrl) => updateProviderDraft("tavily", { baseUrl })}
318
+ />
319
+ <div className="grid gap-4 md:grid-cols-2">
320
+ <SearchSelectField
321
+ label={t("searchProviderSearchDepth")}
322
+ value={tavily.searchDepth}
323
+ options={SEARCH_DEPTH_OPTIONS}
324
+ onChange={(searchDepth) =>
325
+ updateProviderDraft("tavily", {
326
+ searchDepth: searchDepth as TavilySearchDepthValue,
327
+ })
328
+ }
329
+ />
330
+ <SearchSelectField
331
+ label={t("searchProviderIncludeAnswer")}
332
+ value={tavily.includeAnswer ? "true" : "false"}
333
+ options={[
334
+ { value: "true", label: "enabled" },
335
+ { value: "false", label: "disabled" },
336
+ ]}
337
+ onChange={(value) =>
338
+ updateProviderDraft("tavily", {
339
+ includeAnswer: value === "true",
340
+ })
341
+ }
342
+ />
343
+ </div>
344
+ <SearchDocsButton docsUrl={selectedDocsUrl} />
345
+ </>
346
+ );
347
+ }
329
348
 
330
- if (!search || providers.length === 0) {
331
- return <div className="p-8">{t('loading')}</div>;
332
- }
349
+ const brave = draft.providers.brave;
350
+ return (
351
+ <>
352
+ <SearchTextField
353
+ label={t("apiKey")}
354
+ value={brave.apiKey}
355
+ onChange={(apiKey) => updateProviderDraft("brave", { apiKey })}
356
+ placeholder={search.providers.brave.apiKeyMasked || t("enterApiKey")}
357
+ type="password"
358
+ />
359
+ <SearchTextField
360
+ label={t("searchProviderBaseUrl")}
361
+ value={brave.baseUrl}
362
+ onChange={(baseUrl) => updateProviderDraft("brave", { baseUrl })}
363
+ />
364
+ </>
365
+ );
366
+ };
333
367
 
334
368
  return (
335
- <PageLayout>
336
- <PageHeader title={t('searchPageTitle')} description={t('searchPageDescription')} />
369
+ <PageLayout className="pb-0 xl:flex xl:h-full xl:min-h-0 xl:flex-col">
370
+ <PageHeader
371
+ title={t("searchPageTitle")}
372
+ description={t("searchPageDescription")}
373
+ />
337
374
 
338
- <div className={CONFIG_SPLIT_GRID_CLASS}>
339
- <section className={CONFIG_SIDEBAR_CARD_CLASS}>
340
- <div className="border-b border-gray-100 px-4 py-4">
341
- <p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500">{t('searchChannels')}</p>
342
- </div>
343
- <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
375
+ <ConfigSplitPage className="xl:min-h-0">
376
+ <ConfigSplitSidebar>
377
+ <ConfigSplitPaneHeader className="px-4 py-4">
378
+ <p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500">
379
+ {t("searchChannels")}
380
+ </p>
381
+ </ConfigSplitPaneHeader>
382
+ <ConfigSplitPaneBody className="space-y-2 p-3">
344
383
  {providers.map((provider) => {
345
384
  const providerView = search.providers[provider.name];
346
- const isEnabled = enabledProviders.includes(provider.name);
347
- const isSelected = selectedProvider === provider.name;
385
+ const isEnabled = draft.enabledProviders.includes(provider.name);
348
386
  return (
349
- <button
387
+ <ConfigSelectionCard
350
388
  key={provider.name}
351
- type="button"
352
389
  onClick={() => setSelectedProvider(provider.name)}
353
- className={cn(
354
- 'w-full rounded-xl border p-3 text-left transition-all',
355
- isSelected
356
- ? 'border-primary/30 bg-primary-50/40 shadow-sm'
357
- : 'border-gray-200/70 bg-white hover:border-gray-300 hover:bg-gray-50/70'
358
- )}
390
+ active={selectedProvider === provider.name}
391
+ className="p-3"
359
392
  >
360
393
  <div className="flex items-start justify-between gap-3">
361
394
  <div className="min-w-0">
362
- <p className="truncate text-sm font-semibold text-gray-900">{provider.displayName}</p>
395
+ <p className="truncate text-sm font-semibold text-gray-900">
396
+ {provider.displayName}
397
+ </p>
363
398
  <p className="line-clamp-2 text-[11px] text-gray-500">
364
399
  {t(SEARCH_PROVIDER_DESCRIPTION_KEYS[provider.name])}
365
400
  </p>
366
401
  </div>
367
402
  <div className="flex flex-col items-end gap-1">
368
403
  <span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
369
- {providerView.apiKeySet ? t('searchStatusConfigured') : t('searchStatusNeedsSetup')}
404
+ {providerView.apiKeySet
405
+ ? t("searchStatusConfigured")
406
+ : t("searchStatusNeedsSetup")}
370
407
  </span>
371
408
  {isEnabled ? (
372
409
  <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
373
- {t('searchProviderActivated')}
410
+ {t("searchProviderActivated")}
374
411
  </span>
375
412
  ) : null}
376
413
  </div>
377
414
  </div>
378
- </button>
415
+ </ConfigSelectionCard>
379
416
  );
380
417
  })}
381
- </div>
382
- </section>
418
+ </ConfigSplitPaneBody>
419
+ </ConfigSplitSidebar>
383
420
 
384
- <form onSubmit={handleSubmit} className={cn(CONFIG_DETAIL_CARD_CLASS, 'p-6')}>
385
- {!selectedMeta || !selectedView ? (
386
- <div className="flex h-full items-center justify-center text-sm text-gray-500">{t('searchNoProviderSelected')}</div>
387
- ) : (
388
- <div className="space-y-6 overflow-y-auto">
389
- <div className="flex items-start justify-between gap-4">
390
- <div className="flex items-center gap-3">
391
- <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-white">
392
- <SearchIcon className="h-5 w-5" />
393
- </div>
394
- <div>
395
- <h3 className="text-lg font-semibold text-gray-900">{selectedMeta.displayName}</h3>
396
- <p className="text-sm text-gray-500">{selectedMeta.description}</p>
421
+ {!selectedMeta ? (
422
+ <ConfigSplitEmptyPane>
423
+ <p className="text-sm text-gray-500">
424
+ {t("searchNoProviderSelected")}
425
+ </p>
426
+ </ConfigSplitEmptyPane>
427
+ ) : (
428
+ <ConfigSplitDetailPane>
429
+ <form
430
+ onSubmit={(event) => {
431
+ event.preventDefault();
432
+ updateSearch.mutate({ data: buildSearchPayload(draft) });
433
+ }}
434
+ className="flex min-h-0 flex-1 flex-col"
435
+ >
436
+ <ConfigSplitPaneHeader className="px-6 py-5">
437
+ <div className="flex items-start justify-between gap-4">
438
+ <div className="flex items-center gap-3">
439
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-white">
440
+ <SearchIcon className="h-5 w-5" />
441
+ </div>
442
+ <div>
443
+ <h3 className="text-lg font-semibold text-gray-900">
444
+ {selectedMeta.displayName}
445
+ </h3>
446
+ <p className="text-sm text-gray-500">
447
+ {selectedMeta.description}
448
+ </p>
449
+ </div>
397
450
  </div>
451
+ <Button
452
+ type="button"
453
+ variant={selectedEnabled ? "secondary" : "outline"}
454
+ className="rounded-xl"
455
+ onClick={handleToggleEnabled}
456
+ >
457
+ {selectedEnabled
458
+ ? t("searchProviderDeactivate")
459
+ : t("searchProviderActivate")}
460
+ </Button>
398
461
  </div>
399
- <Button
400
- type="button"
401
- variant={selectedEnabled ? 'secondary' : 'outline'}
402
- className="rounded-xl"
403
- onClick={handleToggleEnabled}
404
- >
405
- {activationButtonLabel}
406
- </Button>
407
- </div>
462
+ </ConfigSplitPaneHeader>
408
463
 
409
- <div className="grid gap-4 md:grid-cols-2">
410
- <div className="space-y-2">
411
- <Label>{t('searchActiveProvider')}</Label>
412
- <Select value={activeProvider} onValueChange={handleActiveProviderChange}>
413
- <SelectTrigger className="rounded-xl">
414
- <SelectValue />
415
- </SelectTrigger>
416
- <SelectContent>
417
- {providers.map((provider) => (
418
- <SelectItem key={provider.name} value={provider.name}>{provider.displayName}</SelectItem>
419
- ))}
420
- </SelectContent>
421
- </Select>
422
- </div>
464
+ <ConfigSplitPaneBody className="space-y-6 px-6 py-5">
465
+ <div className="grid gap-4 md:grid-cols-2">
466
+ <div className="space-y-2">
467
+ <Label>{t("searchActiveProvider")}</Label>
468
+ <Select
469
+ value={draft.activeProvider}
470
+ onValueChange={(activeProvider) =>
471
+ setDraft({
472
+ ...draft,
473
+ activeProvider: activeProvider as SearchProviderName,
474
+ })
475
+ }
476
+ >
477
+ <SelectTrigger className="rounded-xl">
478
+ <SelectValue />
479
+ </SelectTrigger>
480
+ <SelectContent>
481
+ {providers.map((provider) => (
482
+ <SelectItem key={provider.name} value={provider.name}>
483
+ {provider.displayName}
484
+ </SelectItem>
485
+ ))}
486
+ </SelectContent>
487
+ </Select>
488
+ </div>
423
489
 
424
- <div className="space-y-2">
425
- <Label>{t('searchDefaultMaxResults')}</Label>
426
- <Input
427
- value={maxResults}
428
- onChange={(event) => setMaxResults(event.target.value)}
490
+ <SearchTextField
491
+ label={t("searchDefaultMaxResults")}
492
+ value={draft.maxResults}
493
+ onChange={(maxResults) => setDraft({ ...draft, maxResults })}
429
494
  inputMode="numeric"
430
- className="rounded-xl"
431
495
  />
432
496
  </div>
433
- </div>
434
497
 
435
- {selectedProvider === 'bocha' ? (
436
- <BochaProviderFields
437
- apiKey={bochaApiKey}
438
- apiKeyMasked={search.providers.bocha.apiKeyMasked}
439
- baseUrl={bochaBaseUrl}
440
- summary={bochaSummary}
441
- freshness={bochaFreshness}
442
- docsUrl={selectedDocsUrl}
443
- onApiKeyChange={setBochaApiKey}
444
- onBaseUrlChange={setBochaBaseUrl}
445
- onSummaryChange={setBochaSummary}
446
- onFreshnessChange={setBochaFreshness}
447
- />
448
- ) : selectedProvider === 'tavily' ? (
449
- <TavilyProviderFields
450
- apiKey={tavilyApiKey}
451
- apiKeyMasked={search.providers.tavily.apiKeyMasked}
452
- baseUrl={tavilyBaseUrl}
453
- searchDepth={tavilySearchDepth}
454
- includeAnswer={tavilyIncludeAnswer}
455
- docsUrl={selectedDocsUrl}
456
- onApiKeyChange={setTavilyApiKey}
457
- onBaseUrlChange={setTavilyBaseUrl}
458
- onSearchDepthChange={setTavilySearchDepth}
459
- onIncludeAnswerChange={setTavilyIncludeAnswer}
460
- />
461
- ) : (
462
- <BraveProviderFields
463
- apiKey={braveApiKey}
464
- apiKeyMasked={search.providers.brave.apiKeyMasked}
465
- baseUrl={braveBaseUrl}
466
- onApiKeyChange={setBraveApiKey}
467
- onBaseUrlChange={setBraveBaseUrl}
468
- />
469
- )}
498
+ {renderProviderFields()}
499
+ </ConfigSplitPaneBody>
470
500
 
471
- <div className="flex justify-end">
501
+ <ConfigSplitPaneFooter className="flex justify-end px-6 py-4">
472
502
  <Button type="submit" disabled={updateSearch.isPending}>
473
503
  <KeyRound className="mr-2 h-4 w-4" />
474
- {updateSearch.isPending ? t('saving') : t('saveChanges')}
504
+ {updateSearch.isPending ? t("saving") : t("saveChanges")}
475
505
  </Button>
476
- </div>
477
- </div>
478
- )}
479
- </form>
480
- </div>
506
+ </ConfigSplitPaneFooter>
507
+ </form>
508
+ </ConfigSplitDetailPane>
509
+ )}
510
+ </ConfigSplitPage>
481
511
  </PageLayout>
482
512
  );
483
513
  }