reigncode-app 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,158 @@
1
+ const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
2
+ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
3
+
4
+ type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
5
+
6
+ export type ModelErr = {
7
+ id?: string
8
+ name?: string
9
+ }
10
+
11
+ export type HeaderErr = {
12
+ key?: string
13
+ value?: string
14
+ }
15
+
16
+ export type ModelRow = {
17
+ row: string
18
+ id: string
19
+ name: string
20
+ err: ModelErr
21
+ }
22
+
23
+ export type HeaderRow = {
24
+ row: string
25
+ key: string
26
+ value: string
27
+ err: HeaderErr
28
+ }
29
+
30
+ export type FormState = {
31
+ providerID: string
32
+ name: string
33
+ baseURL: string
34
+ apiKey: string
35
+ models: ModelRow[]
36
+ headers: HeaderRow[]
37
+ err: {
38
+ providerID?: string
39
+ name?: string
40
+ baseURL?: string
41
+ }
42
+ }
43
+
44
+ type ValidateArgs = {
45
+ form: FormState
46
+ t: Translator
47
+ disabledProviders: string[]
48
+ existingProviderIDs: Set<string>
49
+ }
50
+
51
+ export function validateCustomProvider(input: ValidateArgs) {
52
+ const providerID = input.form.providerID.trim()
53
+ const name = input.form.name.trim()
54
+ const baseURL = input.form.baseURL.trim()
55
+ const apiKey = input.form.apiKey.trim()
56
+
57
+ const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
58
+ const key = apiKey && !env ? apiKey : undefined
59
+
60
+ const idError = !providerID
61
+ ? input.t("provider.custom.error.providerID.required")
62
+ : !PROVIDER_ID.test(providerID)
63
+ ? input.t("provider.custom.error.providerID.format")
64
+ : undefined
65
+
66
+ const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
67
+ const urlError = !baseURL
68
+ ? input.t("provider.custom.error.baseURL.required")
69
+ : !/^https?:\/\//.test(baseURL)
70
+ ? input.t("provider.custom.error.baseURL.format")
71
+ : undefined
72
+
73
+ const disabled = input.disabledProviders.includes(providerID)
74
+ const existsError = idError
75
+ ? undefined
76
+ : input.existingProviderIDs.has(providerID) && !disabled
77
+ ? input.t("provider.custom.error.providerID.exists")
78
+ : undefined
79
+
80
+ const seenModels = new Set<string>()
81
+ const models = input.form.models.map((m) => {
82
+ const id = m.id.trim()
83
+ const idError = !id
84
+ ? input.t("provider.custom.error.required")
85
+ : seenModels.has(id)
86
+ ? input.t("provider.custom.error.duplicate")
87
+ : (() => {
88
+ seenModels.add(id)
89
+ return undefined
90
+ })()
91
+ const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
92
+ return { id: idError, name: nameError }
93
+ })
94
+ const modelsValid = models.every((m) => !m.id && !m.name)
95
+ const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
96
+
97
+ const seenHeaders = new Set<string>()
98
+ const headers = input.form.headers.map((h) => {
99
+ const key = h.key.trim()
100
+ const value = h.value.trim()
101
+
102
+ if (!key && !value) return {}
103
+ const keyError = !key
104
+ ? input.t("provider.custom.error.required")
105
+ : seenHeaders.has(key.toLowerCase())
106
+ ? input.t("provider.custom.error.duplicate")
107
+ : (() => {
108
+ seenHeaders.add(key.toLowerCase())
109
+ return undefined
110
+ })()
111
+ const valueError = !value ? input.t("provider.custom.error.required") : undefined
112
+ return { key: keyError, value: valueError }
113
+ })
114
+ const headersValid = headers.every((h) => !h.key && !h.value)
115
+ const headerConfig = Object.fromEntries(
116
+ input.form.headers
117
+ .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
118
+ .filter((h) => !!h.key && !!h.value)
119
+ .map((h) => [h.key, h.value]),
120
+ )
121
+
122
+ const err = {
123
+ providerID: idError ?? existsError,
124
+ name: nameError,
125
+ baseURL: urlError,
126
+ }
127
+
128
+ const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
129
+ if (!ok) return { err, models, headers }
130
+
131
+ return {
132
+ err,
133
+ models,
134
+ headers,
135
+ result: {
136
+ providerID,
137
+ name,
138
+ key,
139
+ config: {
140
+ npm: OPENAI_COMPATIBLE,
141
+ name,
142
+ ...(env ? { env: [env] } : {}),
143
+ options: {
144
+ baseURL,
145
+ ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
146
+ },
147
+ models: modelConfig,
148
+ },
149
+ },
150
+ }
151
+ }
152
+
153
+ let row = 0
154
+
155
+ const nextRow = () => `row-${row++}`
156
+
157
+ export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
158
+ export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { validateCustomProvider } from "./dialog-custom-provider-form"
3
+
4
+ const t = (key: string) => key
5
+
6
+ describe("validateCustomProvider", () => {
7
+ test("builds trimmed config payload", () => {
8
+ const result = validateCustomProvider({
9
+ form: {
10
+ providerID: "custom-provider",
11
+ name: " Custom Provider ",
12
+ baseURL: "https://api.example.com ",
13
+ apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
14
+ models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
15
+ headers: [
16
+ { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
17
+ { row: "h1", key: "", value: "", err: {} },
18
+ ],
19
+ err: {},
20
+ },
21
+ t,
22
+ disabledProviders: [],
23
+ existingProviderIDs: new Set(),
24
+ })
25
+
26
+ expect(result.result).toEqual({
27
+ providerID: "custom-provider",
28
+ name: "Custom Provider",
29
+ key: undefined,
30
+ config: {
31
+ npm: "@ai-sdk/openai-compatible",
32
+ name: "Custom Provider",
33
+ env: ["CUSTOM_PROVIDER_KEY"],
34
+ options: {
35
+ baseURL: "https://api.example.com",
36
+ headers: {
37
+ "X-Test": "enabled",
38
+ },
39
+ },
40
+ models: {
41
+ "model-a": { name: "Model A" },
42
+ },
43
+ },
44
+ })
45
+ })
46
+
47
+ test("flags duplicate rows and allows reconnecting disabled providers", () => {
48
+ const result = validateCustomProvider({
49
+ form: {
50
+ providerID: "custom-provider",
51
+ name: "Provider",
52
+ baseURL: "https://api.example.com",
53
+ apiKey: "secret",
54
+ models: [
55
+ { row: "m0", id: "model-a", name: "Model A", err: {} },
56
+ { row: "m1", id: "model-a", name: "Model A 2", err: {} },
57
+ ],
58
+ headers: [
59
+ { row: "h0", key: "Authorization", value: "one", err: {} },
60
+ { row: "h1", key: "authorization", value: "two", err: {} },
61
+ ],
62
+ err: {},
63
+ },
64
+ t,
65
+ disabledProviders: ["custom-provider"],
66
+ existingProviderIDs: new Set(["custom-provider"]),
67
+ })
68
+
69
+ expect(result.result).toBeUndefined()
70
+ expect(result.err.providerID).toBeUndefined()
71
+ expect(result.models[1]).toEqual({
72
+ id: "provider.custom.error.duplicate",
73
+ name: undefined,
74
+ })
75
+ expect(result.headers[1]).toEqual({
76
+ key: "provider.custom.error.duplicate",
77
+ value: undefined,
78
+ })
79
+ })
80
+ })
@@ -0,0 +1,329 @@
1
+ import { Button } from "@reign-labs/ui/button"
2
+ import { useDialog } from "@reign-labs/ui/context/dialog"
3
+ import { Dialog } from "@reign-labs/ui/dialog"
4
+ import { IconButton } from "@reign-labs/ui/icon-button"
5
+ import { ProviderIcon } from "@reign-labs/ui/provider-icon"
6
+ import { useMutation } from "@tanstack/solid-query"
7
+ import { TextField } from "@reign-labs/ui/text-field"
8
+ import { showToast } from "@reign-labs/ui/toast"
9
+ import { batch, For } from "solid-js"
10
+ import { createStore, produce } from "solid-js/store"
11
+ import { Link } from "@/components/link"
12
+ import { useGlobalSDK } from "@/context/global-sdk"
13
+ import { useGlobalSync } from "@/context/global-sync"
14
+ import { useLanguage } from "@/context/language"
15
+ import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
16
+ import { DialogSelectProvider } from "./dialog-select-provider"
17
+
18
+ type Props = {
19
+ back?: "providers" | "close"
20
+ }
21
+
22
+ export function DialogCustomProvider(props: Props) {
23
+ const dialog = useDialog()
24
+ const globalSync = useGlobalSync()
25
+ const globalSDK = useGlobalSDK()
26
+ const language = useLanguage()
27
+
28
+ const [form, setForm] = createStore<FormState>({
29
+ providerID: "",
30
+ name: "",
31
+ baseURL: "",
32
+ apiKey: "",
33
+ models: [modelRow()],
34
+ headers: [headerRow()],
35
+ err: {},
36
+ })
37
+
38
+ const goBack = () => {
39
+ if (props.back === "close") {
40
+ dialog.close()
41
+ return
42
+ }
43
+ dialog.show(() => <DialogSelectProvider />)
44
+ }
45
+
46
+ const addModel = () => {
47
+ setForm(
48
+ "models",
49
+ produce((rows) => {
50
+ rows.push(modelRow())
51
+ }),
52
+ )
53
+ }
54
+
55
+ const removeModel = (index: number) => {
56
+ if (form.models.length <= 1) return
57
+ setForm(
58
+ "models",
59
+ produce((rows) => {
60
+ rows.splice(index, 1)
61
+ }),
62
+ )
63
+ }
64
+
65
+ const addHeader = () => {
66
+ setForm(
67
+ "headers",
68
+ produce((rows) => {
69
+ rows.push(headerRow())
70
+ }),
71
+ )
72
+ }
73
+
74
+ const removeHeader = (index: number) => {
75
+ if (form.headers.length <= 1) return
76
+ setForm(
77
+ "headers",
78
+ produce((rows) => {
79
+ rows.splice(index, 1)
80
+ }),
81
+ )
82
+ }
83
+
84
+ const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
85
+ setForm(key, value)
86
+ if (key === "apiKey") return
87
+ setForm("err", key, undefined)
88
+ }
89
+
90
+ const setModel = (index: number, key: "id" | "name", value: string) => {
91
+ batch(() => {
92
+ setForm("models", index, key, value)
93
+ setForm("models", index, "err", key, undefined)
94
+ })
95
+ }
96
+
97
+ const setHeader = (index: number, key: "key" | "value", value: string) => {
98
+ batch(() => {
99
+ setForm("headers", index, key, value)
100
+ setForm("headers", index, "err", key, undefined)
101
+ })
102
+ }
103
+
104
+ const validate = () => {
105
+ const output = validateCustomProvider({
106
+ form,
107
+ t: language.t,
108
+ disabledProviders: globalSync.data.config.disabled_providers ?? [],
109
+ existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
110
+ })
111
+ batch(() => {
112
+ setForm("err", output.err)
113
+ output.models.forEach((err, index) => setForm("models", index, "err", err))
114
+ output.headers.forEach((err, index) => setForm("headers", index, "err", err))
115
+ })
116
+ return output.result
117
+ }
118
+
119
+ const saveMutation = useMutation(() => ({
120
+ mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
121
+ const disabledProviders = globalSync.data.config.disabled_providers ?? []
122
+ const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
123
+
124
+ if (result.key) {
125
+ await globalSDK.client.auth.set({
126
+ providerID: result.providerID,
127
+ auth: {
128
+ type: "api",
129
+ key: result.key,
130
+ },
131
+ })
132
+ }
133
+
134
+ await globalSync.updateConfig({
135
+ provider: { [result.providerID]: result.config },
136
+ disabled_providers: nextDisabled,
137
+ })
138
+ return result
139
+ },
140
+ onSuccess: (result) => {
141
+ dialog.close()
142
+ showToast({
143
+ variant: "success",
144
+ icon: "circle-check",
145
+ title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
146
+ description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
147
+ })
148
+ },
149
+ onError: (err) => {
150
+ const message = err instanceof Error ? err.message : String(err)
151
+ showToast({ title: language.t("common.requestFailed"), description: message })
152
+ },
153
+ }))
154
+
155
+ const save = (e: SubmitEvent) => {
156
+ e.preventDefault()
157
+ if (saveMutation.isPending) return
158
+
159
+ const result = validate()
160
+ if (!result) return
161
+ saveMutation.mutate(result)
162
+ }
163
+
164
+ return (
165
+ <Dialog
166
+ title={
167
+ <IconButton
168
+ tabIndex={-1}
169
+ icon="arrow-left"
170
+ variant="ghost"
171
+ onClick={goBack}
172
+ aria-label={language.t("common.goBack")}
173
+ />
174
+ }
175
+ transition
176
+ >
177
+ <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
178
+ <div class="px-2.5 flex gap-4 items-center">
179
+ <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
180
+ <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
181
+ </div>
182
+
183
+ <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
184
+ <p class="text-14-regular text-text-base">
185
+ {language.t("provider.custom.description.prefix")}
186
+ <Link href="https://code.reign-labs.com/docs/providers/#custom-provider" tabIndex={-1}>
187
+ {language.t("provider.custom.description.link")}
188
+ </Link>
189
+ {language.t("provider.custom.description.suffix")}
190
+ </p>
191
+
192
+ <div class="flex flex-col gap-4">
193
+ <TextField
194
+ autofocus
195
+ label={language.t("provider.custom.field.providerID.label")}
196
+ placeholder={language.t("provider.custom.field.providerID.placeholder")}
197
+ description={language.t("provider.custom.field.providerID.description")}
198
+ value={form.providerID}
199
+ onChange={(v) => setField("providerID", v)}
200
+ validationState={form.err.providerID ? "invalid" : undefined}
201
+ error={form.err.providerID}
202
+ />
203
+ <TextField
204
+ label={language.t("provider.custom.field.name.label")}
205
+ placeholder={language.t("provider.custom.field.name.placeholder")}
206
+ value={form.name}
207
+ onChange={(v) => setField("name", v)}
208
+ validationState={form.err.name ? "invalid" : undefined}
209
+ error={form.err.name}
210
+ />
211
+ <TextField
212
+ label={language.t("provider.custom.field.baseURL.label")}
213
+ placeholder={language.t("provider.custom.field.baseURL.placeholder")}
214
+ value={form.baseURL}
215
+ onChange={(v) => setField("baseURL", v)}
216
+ validationState={form.err.baseURL ? "invalid" : undefined}
217
+ error={form.err.baseURL}
218
+ />
219
+ <TextField
220
+ label={language.t("provider.custom.field.apiKey.label")}
221
+ placeholder={language.t("provider.custom.field.apiKey.placeholder")}
222
+ description={language.t("provider.custom.field.apiKey.description")}
223
+ value={form.apiKey}
224
+ onChange={(v) => setField("apiKey", v)}
225
+ />
226
+ </div>
227
+
228
+ <div class="flex flex-col gap-3">
229
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
230
+ <For each={form.models}>
231
+ {(m, i) => (
232
+ <div class="flex gap-2 items-start" data-row={m.row}>
233
+ <div class="flex-1">
234
+ <TextField
235
+ label={language.t("provider.custom.models.id.label")}
236
+ hideLabel
237
+ placeholder={language.t("provider.custom.models.id.placeholder")}
238
+ value={m.id}
239
+ onChange={(v) => setModel(i(), "id", v)}
240
+ validationState={m.err.id ? "invalid" : undefined}
241
+ error={m.err.id}
242
+ />
243
+ </div>
244
+ <div class="flex-1">
245
+ <TextField
246
+ label={language.t("provider.custom.models.name.label")}
247
+ hideLabel
248
+ placeholder={language.t("provider.custom.models.name.placeholder")}
249
+ value={m.name}
250
+ onChange={(v) => setModel(i(), "name", v)}
251
+ validationState={m.err.name ? "invalid" : undefined}
252
+ error={m.err.name}
253
+ />
254
+ </div>
255
+ <IconButton
256
+ type="button"
257
+ icon="trash"
258
+ variant="ghost"
259
+ class="mt-1.5"
260
+ onClick={() => removeModel(i())}
261
+ disabled={form.models.length <= 1}
262
+ aria-label={language.t("provider.custom.models.remove")}
263
+ />
264
+ </div>
265
+ )}
266
+ </For>
267
+ <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
268
+ {language.t("provider.custom.models.add")}
269
+ </Button>
270
+ </div>
271
+
272
+ <div class="flex flex-col gap-3">
273
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
274
+ <For each={form.headers}>
275
+ {(h, i) => (
276
+ <div class="flex gap-2 items-start" data-row={h.row}>
277
+ <div class="flex-1">
278
+ <TextField
279
+ label={language.t("provider.custom.headers.key.label")}
280
+ hideLabel
281
+ placeholder={language.t("provider.custom.headers.key.placeholder")}
282
+ value={h.key}
283
+ onChange={(v) => setHeader(i(), "key", v)}
284
+ validationState={h.err.key ? "invalid" : undefined}
285
+ error={h.err.key}
286
+ />
287
+ </div>
288
+ <div class="flex-1">
289
+ <TextField
290
+ label={language.t("provider.custom.headers.value.label")}
291
+ hideLabel
292
+ placeholder={language.t("provider.custom.headers.value.placeholder")}
293
+ value={h.value}
294
+ onChange={(v) => setHeader(i(), "value", v)}
295
+ validationState={h.err.value ? "invalid" : undefined}
296
+ error={h.err.value}
297
+ />
298
+ </div>
299
+ <IconButton
300
+ type="button"
301
+ icon="trash"
302
+ variant="ghost"
303
+ class="mt-1.5"
304
+ onClick={() => removeHeader(i())}
305
+ disabled={form.headers.length <= 1}
306
+ aria-label={language.t("provider.custom.headers.remove")}
307
+ />
308
+ </div>
309
+ )}
310
+ </For>
311
+ <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
312
+ {language.t("provider.custom.headers.add")}
313
+ </Button>
314
+ </div>
315
+
316
+ <Button
317
+ class="w-auto self-start"
318
+ type="submit"
319
+ size="large"
320
+ variant="primary"
321
+ disabled={saveMutation.isPending}
322
+ >
323
+ {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
324
+ </Button>
325
+ </form>
326
+ </div>
327
+ </Dialog>
328
+ )
329
+ }