sparkecoder 0.1.98 → 0.1.100

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 (305) hide show
  1. package/README.md +41 -41
  2. package/dist/agent/index.d.ts +3 -3
  3. package/dist/agent/index.js +1274 -59
  4. package/dist/agent/index.js.map +1 -1
  5. package/dist/cli.js +10671 -8973
  6. package/dist/cli.js.map +1 -1
  7. package/dist/db/index.d.ts +2 -2
  8. package/dist/{index-BvIissiB.d.ts → index-D5l-DMGC.d.ts} +390 -6
  9. package/dist/index.d.ts +5 -5
  10. package/dist/index.js +10002 -8096
  11. package/dist/index.js.map +1 -1
  12. package/dist/{schema-CohdIL13.d.ts → schema-ecQSnCMz.d.ts} +41 -0
  13. package/dist/server/index.js +8867 -6969
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/skills/default/manage-mcp.md +94 -0
  16. package/dist/skills/default/search-conversations.md +100 -0
  17. package/dist/tools/index.d.ts +2 -2
  18. package/dist/tools/index.js +111 -2
  19. package/dist/tools/index.js.map +1 -1
  20. package/package.json +5 -1
  21. package/src/skills/default/manage-mcp.md +94 -0
  22. package/src/skills/default/search-conversations.md +100 -0
  23. package/web/.next/BUILD_ID +1 -1
  24. package/web/.next/standalone/web/.next/BUILD_ID +1 -1
  25. package/web/.next/standalone/web/.next/app-path-routes-manifest.json +2 -1
  26. package/web/.next/standalone/web/.next/build-manifest.json +5 -5
  27. package/web/.next/standalone/web/.next/prerender-manifest.json +51 -3
  28. package/web/.next/standalone/web/.next/routes-manifest.json +13 -24
  29. package/web/.next/standalone/web/.next/server/app/(main)/agents/page/app-paths-manifest.json +3 -0
  30. package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page/build-manifest.json +3 -3
  31. package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page/next-font-manifest.json +1 -1
  32. package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page.js +7 -6
  33. package/web/.next/standalone/web/.next/server/app/(main)/agents/page.js.nft.json +1 -0
  34. package/web/.next/standalone/web/.next/server/app/(main)/agents/page_client-reference-manifest.js +2 -0
  35. package/web/.next/standalone/web/.next/server/app/(main)/page/build-manifest.json +3 -3
  36. package/web/.next/standalone/web/.next/server/app/(main)/page.js.nft.json +1 -1
  37. package/web/.next/standalone/web/.next/server/app/(main)/page_client-reference-manifest.js +1 -1
  38. package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page/build-manifest.json +3 -3
  39. package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page.js.nft.json +1 -1
  40. package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page_client-reference-manifest.js +1 -1
  41. package/web/.next/standalone/web/.next/server/app/(main)/settings/page/app-paths-manifest.json +3 -0
  42. package/web/.next/standalone/web/.next/server/app/(main)/settings/page/build-manifest.json +18 -0
  43. package/web/.next/standalone/web/.next/server/app/(main)/settings/page/next-font-manifest.json +11 -0
  44. package/web/.next/standalone/web/.next/server/app/(main)/settings/page/react-loadable-manifest.json +1 -0
  45. package/web/.next/standalone/web/.next/server/app/(main)/settings/page/server-reference-manifest.json +4 -0
  46. package/web/.next/standalone/web/.next/server/app/(main)/settings/page.js +21 -0
  47. package/web/.next/standalone/web/.next/server/app/(main)/settings/page.js.map +5 -0
  48. package/web/.next/standalone/web/.next/server/app/(main)/settings/page.js.nft.json +1 -0
  49. package/web/.next/standalone/web/.next/server/app/(main)/settings/page_client-reference-manifest.js +2 -0
  50. package/web/.next/standalone/web/.next/server/app/_global-error/page/build-manifest.json +3 -3
  51. package/web/.next/standalone/web/.next/server/app/_global-error.html +2 -2
  52. package/web/.next/standalone/web/.next/server/app/_global-error.rsc +1 -1
  53. package/web/.next/standalone/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  54. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  56. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  57. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  58. package/web/.next/standalone/web/.next/server/app/_not-found/page/build-manifest.json +3 -3
  59. package/web/.next/standalone/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  60. package/web/.next/standalone/web/.next/server/app/_not-found.html +1 -1
  61. package/web/.next/standalone/web/.next/server/app/_not-found.rsc +3 -3
  62. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  63. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  65. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  68. package/web/.next/standalone/web/.next/server/app/agents.html +1 -0
  69. package/web/.next/standalone/web/.next/server/app/agents.meta +16 -0
  70. package/web/.next/standalone/web/.next/server/app/agents.rsc +25 -0
  71. package/web/.next/standalone/web/.next/server/app/agents.segments/!KG1haW4p/agents/__PAGE__.segment.rsc +9 -0
  72. package/web/.next/standalone/web/.next/server/app/agents.segments/!KG1haW4p/agents.segment.rsc +4 -0
  73. package/web/.next/standalone/web/.next/server/app/agents.segments/!KG1haW4p.segment.rsc +7 -0
  74. package/web/.next/standalone/web/.next/server/app/agents.segments/_full.segment.rsc +25 -0
  75. package/web/.next/standalone/web/.next/server/app/agents.segments/_head.segment.rsc +6 -0
  76. package/web/.next/standalone/web/.next/server/app/agents.segments/_index.segment.rsc +7 -0
  77. package/web/.next/standalone/web/.next/server/app/agents.segments/_tree.segment.rsc +5 -0
  78. package/web/.next/standalone/web/.next/server/app/api/config/route.js.nft.json +1 -1
  79. package/web/.next/standalone/web/.next/server/app/api/health/route.js.nft.json +1 -1
  80. package/web/.next/standalone/web/.next/server/app/docs/installation/page/build-manifest.json +3 -3
  81. package/web/.next/standalone/web/.next/server/app/docs/installation/page_client-reference-manifest.js +1 -1
  82. package/web/.next/standalone/web/.next/server/app/docs/installation.html +2 -2
  83. package/web/.next/standalone/web/.next/server/app/docs/installation.rsc +4 -4
  84. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/_full.segment.rsc +4 -4
  85. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/_head.segment.rsc +1 -1
  86. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/_index.segment.rsc +3 -3
  87. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/_tree.segment.rsc +2 -2
  88. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/docs/installation/__PAGE__.segment.rsc +2 -2
  89. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/docs/installation.segment.rsc +1 -1
  90. package/web/.next/standalone/web/.next/server/app/docs/installation.segments/docs.segment.rsc +2 -2
  91. package/web/.next/standalone/web/.next/server/app/docs/page/build-manifest.json +3 -3
  92. package/web/.next/standalone/web/.next/server/app/docs/page_client-reference-manifest.js +1 -1
  93. package/web/.next/standalone/web/.next/server/app/docs/skills/page/build-manifest.json +3 -3
  94. package/web/.next/standalone/web/.next/server/app/docs/skills/page_client-reference-manifest.js +1 -1
  95. package/web/.next/standalone/web/.next/server/app/docs/skills.html +2 -2
  96. package/web/.next/standalone/web/.next/server/app/docs/skills.rsc +4 -4
  97. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/_full.segment.rsc +4 -4
  98. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/_head.segment.rsc +1 -1
  99. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/_index.segment.rsc +3 -3
  100. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/_tree.segment.rsc +2 -2
  101. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/docs/skills/__PAGE__.segment.rsc +1 -1
  102. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/docs/skills.segment.rsc +1 -1
  103. package/web/.next/standalone/web/.next/server/app/docs/skills.segments/docs.segment.rsc +2 -2
  104. package/web/.next/standalone/web/.next/server/app/docs/tools/page/build-manifest.json +3 -3
  105. package/web/.next/standalone/web/.next/server/app/docs/tools/page_client-reference-manifest.js +1 -1
  106. package/web/.next/standalone/web/.next/server/app/docs/tools.html +2 -2
  107. package/web/.next/standalone/web/.next/server/app/docs/tools.rsc +4 -4
  108. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/_full.segment.rsc +4 -4
  109. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/_head.segment.rsc +1 -1
  110. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/_index.segment.rsc +3 -3
  111. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/_tree.segment.rsc +2 -2
  112. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/docs/tools/__PAGE__.segment.rsc +2 -2
  113. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/docs/tools.segment.rsc +1 -1
  114. package/web/.next/standalone/web/.next/server/app/docs/tools.segments/docs.segment.rsc +2 -2
  115. package/web/.next/standalone/web/.next/server/app/docs.html +2 -2
  116. package/web/.next/standalone/web/.next/server/app/docs.rsc +4 -4
  117. package/web/.next/standalone/web/.next/server/app/docs.segments/_full.segment.rsc +4 -4
  118. package/web/.next/standalone/web/.next/server/app/docs.segments/_head.segment.rsc +1 -1
  119. package/web/.next/standalone/web/.next/server/app/docs.segments/_index.segment.rsc +3 -3
  120. package/web/.next/standalone/web/.next/server/app/docs.segments/_tree.segment.rsc +2 -2
  121. package/web/.next/standalone/web/.next/server/app/docs.segments/docs/__PAGE__.segment.rsc +2 -2
  122. package/web/.next/standalone/web/.next/server/app/docs.segments/docs.segment.rsc +2 -2
  123. package/web/.next/standalone/web/.next/server/app/index.html +1 -1
  124. package/web/.next/standalone/web/.next/server/app/index.rsc +5 -5
  125. package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p/__PAGE__.segment.rsc +2 -2
  126. package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p.segment.rsc +2 -2
  127. package/web/.next/standalone/web/.next/server/app/index.segments/_full.segment.rsc +5 -5
  128. package/web/.next/standalone/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  129. package/web/.next/standalone/web/.next/server/app/index.segments/_index.segment.rsc +3 -3
  130. package/web/.next/standalone/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  131. package/web/.next/standalone/web/.next/server/app/settings.html +1 -0
  132. package/web/.next/standalone/web/.next/server/app/settings.meta +16 -0
  133. package/web/.next/standalone/web/.next/server/app/settings.rsc +25 -0
  134. package/web/.next/standalone/web/.next/server/app/settings.segments/!KG1haW4p/settings/__PAGE__.segment.rsc +9 -0
  135. package/web/.next/standalone/web/.next/server/app/settings.segments/!KG1haW4p/settings.segment.rsc +4 -0
  136. package/web/.next/standalone/web/.next/server/app/settings.segments/!KG1haW4p.segment.rsc +7 -0
  137. package/web/.next/standalone/web/.next/server/app/settings.segments/_full.segment.rsc +25 -0
  138. package/web/.next/standalone/web/.next/server/app/settings.segments/_head.segment.rsc +6 -0
  139. package/web/.next/standalone/web/.next/server/app/settings.segments/_index.segment.rsc +7 -0
  140. package/web/.next/standalone/web/.next/server/app/settings.segments/_tree.segment.rsc +5 -0
  141. package/web/.next/standalone/web/.next/server/app-paths-manifest.json +2 -1
  142. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_a383a4d9._.js → 2374f_1f3f2d00._.js} +1 -1
  143. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_60d8842c._.js → 2374f_38945fd9._.js} +1 -1
  144. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_f363c084._.js → 2374f_570c34dc._.js} +1 -1
  145. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_806bd012._.js → 2374f_9c560f3a._.js} +1 -1
  146. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_b7f45fdf._.js → 2374f_a0d5caeb._.js} +1 -1
  147. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_c13c8f4f._.js → 2374f_c87abaf4._.js} +1 -1
  148. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_2801b766._.js → 2374f_d8122230._.js} +1 -1
  149. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_lucide-react_dist_esm_icons_50c2f239._.js +3 -0
  150. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__2ea52390._.js +3 -0
  151. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__6097da17._.js +15 -0
  152. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__9f149e88._.js +3 -0
  153. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__b050bb8f._.js +1 -1
  154. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__be5e2967._.js +1 -1
  155. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__c94db61a._.js +3 -0
  156. package/web/.next/standalone/web/.next/server/chunks/ssr/web_3b9a2423._.js +3 -0
  157. package/web/.next/standalone/web/.next/server/chunks/ssr/web_4fe3c244._.js +3 -0
  158. package/web/.next/standalone/web/.next/server/chunks/ssr/{web_3c2b112b._.js → web_7ca56356._.js} +3 -3
  159. package/web/.next/standalone/web/.next/server/chunks/ssr/web_8e76ee8b._.js +8 -0
  160. package/web/.next/standalone/web/.next/server/chunks/ssr/web_90d4125e._.js +7 -0
  161. package/web/.next/standalone/web/.next/server/chunks/ssr/web__next-internal_server_app_(main)_agents_page_actions_30f6e448.js +3 -0
  162. package/web/.next/standalone/web/.next/server/chunks/ssr/web__next-internal_server_app_(main)_settings_page_actions_7285839d.js +3 -0
  163. package/web/.next/standalone/web/.next/server/chunks/ssr/web_b38a47ee._.js +4 -0
  164. package/web/.next/standalone/web/.next/server/chunks/ssr/web_f7cf6b63._.js +3 -0
  165. package/web/.next/standalone/web/.next/server/chunks/ssr/web_src_app_(main)_layout_tsx_453f6492._.js +3 -0
  166. package/web/.next/standalone/web/.next/server/chunks/ssr/web_src_app_(main)_page_tsx_5ac4794b._.js +1 -1
  167. package/web/.next/standalone/web/.next/server/chunks/ssr/web_src_app_(main)_settings_page_tsx_eb320e07._.js +33 -0
  168. package/web/.next/standalone/web/.next/server/middleware-build-manifest.js +3 -3
  169. package/web/.next/standalone/web/.next/server/next-font-manifest.js +1 -1
  170. package/web/.next/standalone/web/.next/server/next-font-manifest.json +8 -4
  171. package/web/.next/standalone/web/.next/server/pages/404.html +1 -1
  172. package/web/.next/standalone/web/.next/server/pages/500.html +2 -2
  173. package/web/.next/standalone/web/.next/server/server-reference-manifest.js +1 -1
  174. package/web/.next/standalone/web/.next/server/server-reference-manifest.json +1 -1
  175. package/web/.next/standalone/web/.next/static/chunks/03b5edce6d5b809e.js +1 -0
  176. package/web/.next/standalone/web/.next/static/chunks/29dcecc3c2ca92b0.js +1 -0
  177. package/web/.next/standalone/web/.next/static/chunks/344be859c2c8600b.css +1 -0
  178. package/web/.next/standalone/web/.next/static/chunks/4239395558fab3ef.js +1 -0
  179. package/web/.next/standalone/web/.next/static/chunks/60359bdd369c0c72.js +1 -0
  180. package/web/.next/standalone/web/.next/static/chunks/699803c4fb2dd3fc.js +5 -0
  181. package/web/.next/standalone/web/.next/static/chunks/735a2408c315b2f0.js +1 -0
  182. package/web/.next/standalone/web/.next/static/chunks/ae4bb24474ff1ed0.js +13 -0
  183. package/web/.next/standalone/web/.next/static/chunks/d54077a2bb8314ed.js +31 -0
  184. package/web/.next/standalone/web/.next/static/chunks/dc34aa94e57fa28e.js +3 -0
  185. package/web/.next/standalone/web/.next/static/chunks/ea89ca7892d8c557.js +1 -0
  186. package/web/.next/standalone/web/.next/static/chunks/f0f19357f3fb7cf8.js +1 -0
  187. package/web/.next/standalone/web/.next/static/chunks/f5fe518b79d1bf41.js +1 -0
  188. package/web/.next/standalone/web/.next/static/chunks/fbdcbd65f9a38f4b.js +7 -0
  189. package/web/.next/{static/chunks/turbopack-597558bb7b6982f6.js → standalone/web/.next/static/chunks/turbopack-2c0905c7bbebae3f.js} +1 -1
  190. package/web/.next/standalone/web/.next/static/static/chunks/03b5edce6d5b809e.js +1 -0
  191. package/web/.next/standalone/web/.next/static/static/chunks/29dcecc3c2ca92b0.js +1 -0
  192. package/web/.next/standalone/web/.next/static/static/chunks/344be859c2c8600b.css +1 -0
  193. package/web/.next/standalone/web/.next/static/static/chunks/4239395558fab3ef.js +1 -0
  194. package/web/.next/standalone/web/.next/static/static/chunks/60359bdd369c0c72.js +1 -0
  195. package/web/.next/standalone/web/.next/static/static/chunks/699803c4fb2dd3fc.js +5 -0
  196. package/web/.next/standalone/web/.next/static/static/chunks/735a2408c315b2f0.js +1 -0
  197. package/web/.next/standalone/web/.next/static/static/chunks/ae4bb24474ff1ed0.js +13 -0
  198. package/web/.next/standalone/web/.next/static/static/chunks/d54077a2bb8314ed.js +31 -0
  199. package/web/.next/standalone/web/.next/static/static/chunks/dc34aa94e57fa28e.js +3 -0
  200. package/web/.next/standalone/web/.next/static/static/chunks/ea89ca7892d8c557.js +1 -0
  201. package/web/.next/standalone/web/.next/static/static/chunks/f0f19357f3fb7cf8.js +1 -0
  202. package/web/.next/standalone/web/.next/static/static/chunks/f5fe518b79d1bf41.js +1 -0
  203. package/web/.next/standalone/web/.next/static/static/chunks/fbdcbd65f9a38f4b.js +7 -0
  204. package/web/.next/standalone/web/.next/static/{chunks/turbopack-597558bb7b6982f6.js → static/chunks/turbopack-2c0905c7bbebae3f.js} +1 -1
  205. package/web/.next/standalone/web/next.config.ts +1 -55
  206. package/web/.next/standalone/web/package-lock.json +7 -7
  207. package/web/.next/standalone/web/src/app/(main)/agents/page.tsx +222 -0
  208. package/web/.next/standalone/web/src/app/(main)/page.tsx +40 -1
  209. package/web/.next/standalone/web/src/app/(main)/session/[id]/page.tsx +9 -1
  210. package/web/.next/standalone/web/src/app/(main)/settings/page.tsx +1116 -0
  211. package/web/.next/standalone/web/src/components/ai-elements/mention-input.tsx +125 -17
  212. package/web/.next/standalone/web/src/components/chat-interface.tsx +205 -4
  213. package/web/.next/standalone/web/src/components/pending-question-banner.tsx +106 -0
  214. package/web/.next/standalone/web/src/components/sessions-sidebar.tsx +120 -50
  215. package/web/.next/standalone/web/src/lib/api.ts +43 -2
  216. package/web/.next/static/chunks/03b5edce6d5b809e.js +1 -0
  217. package/web/.next/static/chunks/29dcecc3c2ca92b0.js +1 -0
  218. package/web/.next/static/chunks/344be859c2c8600b.css +1 -0
  219. package/web/.next/static/chunks/4239395558fab3ef.js +1 -0
  220. package/web/.next/static/chunks/60359bdd369c0c72.js +1 -0
  221. package/web/.next/static/chunks/699803c4fb2dd3fc.js +5 -0
  222. package/web/.next/static/chunks/735a2408c315b2f0.js +1 -0
  223. package/web/.next/static/chunks/ae4bb24474ff1ed0.js +13 -0
  224. package/web/.next/static/chunks/d54077a2bb8314ed.js +31 -0
  225. package/web/.next/static/chunks/dc34aa94e57fa28e.js +3 -0
  226. package/web/.next/static/chunks/ea89ca7892d8c557.js +1 -0
  227. package/web/.next/static/chunks/f0f19357f3fb7cf8.js +1 -0
  228. package/web/.next/static/chunks/f5fe518b79d1bf41.js +1 -0
  229. package/web/.next/static/chunks/fbdcbd65f9a38f4b.js +7 -0
  230. package/web/.next/{standalone/web/.next/static/static/chunks/turbopack-597558bb7b6982f6.js → static/chunks/turbopack-2c0905c7bbebae3f.js} +1 -1
  231. package/web/.next/standalone/web/.next/server/app/embed/[id]/page/app-paths-manifest.json +0 -3
  232. package/web/.next/standalone/web/.next/server/app/embed/[id]/page.js.nft.json +0 -1
  233. package/web/.next/standalone/web/.next/server/app/embed/[id]/page_client-reference-manifest.js +0 -2
  234. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_317b1fef._.js +0 -3
  235. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_37dd9702._.js +0 -45
  236. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_4d44e4ed._.js +0 -26
  237. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_54ac917f._.js +0 -3
  238. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_86585101._.js +0 -3
  239. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_c59a35bb._.js +0 -3
  240. package/web/.next/standalone/web/.next/server/chunks/ssr/2374f_fdfc7f3d._.js +0 -31
  241. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__234f92d8._.js +0 -3
  242. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__9a826344._.js +0 -3
  243. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__9d3a7cbf._.js +0 -15
  244. package/web/.next/standalone/web/.next/server/chunks/ssr/[root-of-the-server]__de76483d._.js +0 -3
  245. package/web/.next/standalone/web/.next/server/chunks/ssr/web_08242997._.js +0 -3
  246. package/web/.next/standalone/web/.next/server/chunks/ssr/web_123ffe97._.js +0 -8
  247. package/web/.next/standalone/web/.next/server/chunks/ssr/web_5cca707f._.js +0 -7
  248. package/web/.next/standalone/web/.next/server/chunks/ssr/web_935e81f5._.js +0 -7
  249. package/web/.next/standalone/web/.next/server/chunks/ssr/web_99b01335._.js +0 -8
  250. package/web/.next/standalone/web/.next/server/chunks/ssr/web__next-internal_server_app_embed_[id]_page_actions_dd0b7fea.js +0 -3
  251. package/web/.next/standalone/web/.next/server/chunks/ssr/web_src_components_sessions-sidebar_tsx_92510070._.js +0 -3
  252. package/web/.next/standalone/web/.next/static/chunks/1ebba7ac024244f9.js +0 -5
  253. package/web/.next/standalone/web/.next/static/chunks/275e8268daf318b2.js +0 -7
  254. package/web/.next/standalone/web/.next/static/chunks/2bf377e04592f3c8.js +0 -13
  255. package/web/.next/standalone/web/.next/static/chunks/376a123113ccf5eb.js +0 -3
  256. package/web/.next/standalone/web/.next/static/chunks/58fd0aaa2746b444.js +0 -1
  257. package/web/.next/standalone/web/.next/static/chunks/61c9922b38a9569d.js +0 -3
  258. package/web/.next/standalone/web/.next/static/chunks/74b64476a24dd71e.css +0 -1
  259. package/web/.next/standalone/web/.next/static/chunks/767bcdfbabf0703e.js +0 -7
  260. package/web/.next/standalone/web/.next/static/chunks/9fce2ce79c4c834e.js +0 -1
  261. package/web/.next/standalone/web/.next/static/chunks/a888d448ceab1abe.js +0 -1
  262. package/web/.next/standalone/web/.next/static/chunks/b9ad1584d4e11d12.js +0 -1
  263. package/web/.next/standalone/web/.next/static/chunks/c6f40df16a9396b9.js +0 -1
  264. package/web/.next/standalone/web/.next/static/chunks/ea29be392100ab0f.js +0 -5
  265. package/web/.next/standalone/web/.next/static/static/chunks/1ebba7ac024244f9.js +0 -5
  266. package/web/.next/standalone/web/.next/static/static/chunks/275e8268daf318b2.js +0 -7
  267. package/web/.next/standalone/web/.next/static/static/chunks/2bf377e04592f3c8.js +0 -13
  268. package/web/.next/standalone/web/.next/static/static/chunks/376a123113ccf5eb.js +0 -3
  269. package/web/.next/standalone/web/.next/static/static/chunks/58fd0aaa2746b444.js +0 -1
  270. package/web/.next/standalone/web/.next/static/static/chunks/61c9922b38a9569d.js +0 -3
  271. package/web/.next/standalone/web/.next/static/static/chunks/74b64476a24dd71e.css +0 -1
  272. package/web/.next/standalone/web/.next/static/static/chunks/767bcdfbabf0703e.js +0 -7
  273. package/web/.next/standalone/web/.next/static/static/chunks/9fce2ce79c4c834e.js +0 -1
  274. package/web/.next/standalone/web/.next/static/static/chunks/a888d448ceab1abe.js +0 -1
  275. package/web/.next/standalone/web/.next/static/static/chunks/b9ad1584d4e11d12.js +0 -1
  276. package/web/.next/standalone/web/.next/static/static/chunks/c6f40df16a9396b9.js +0 -1
  277. package/web/.next/standalone/web/.next/static/static/chunks/ea29be392100ab0f.js +0 -5
  278. package/web/.next/standalone/web/src/app/embed/[id]/page.tsx +0 -77
  279. package/web/.next/standalone/web/src/lib/embed-bootstrap.ts +0 -108
  280. package/web/.next/static/chunks/1ebba7ac024244f9.js +0 -5
  281. package/web/.next/static/chunks/275e8268daf318b2.js +0 -7
  282. package/web/.next/static/chunks/2bf377e04592f3c8.js +0 -13
  283. package/web/.next/static/chunks/376a123113ccf5eb.js +0 -3
  284. package/web/.next/static/chunks/58fd0aaa2746b444.js +0 -1
  285. package/web/.next/static/chunks/61c9922b38a9569d.js +0 -3
  286. package/web/.next/static/chunks/74b64476a24dd71e.css +0 -1
  287. package/web/.next/static/chunks/767bcdfbabf0703e.js +0 -7
  288. package/web/.next/static/chunks/9fce2ce79c4c834e.js +0 -1
  289. package/web/.next/static/chunks/a888d448ceab1abe.js +0 -1
  290. package/web/.next/static/chunks/b9ad1584d4e11d12.js +0 -1
  291. package/web/.next/static/chunks/c6f40df16a9396b9.js +0 -1
  292. package/web/.next/static/chunks/ea29be392100ab0f.js +0 -5
  293. package/dist/{search-CCffrVJE.d.ts → search-DOzC4ojH.d.ts} +1 -1
  294. /package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page/react-loadable-manifest.json +0 -0
  295. /package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page/server-reference-manifest.json +0 -0
  296. /package/web/.next/standalone/web/.next/server/app/{embed/[id] → (main)/agents}/page.js.map +0 -0
  297. /package/web/.next/standalone/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_buildManifest.js +0 -0
  298. /package/web/.next/standalone/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_clientMiddlewareManifest.json +0 -0
  299. /package/web/.next/standalone/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_ssgManifest.js +0 -0
  300. /package/web/.next/standalone/web/.next/static/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_buildManifest.js +0 -0
  301. /package/web/.next/standalone/web/.next/static/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_clientMiddlewareManifest.json +0 -0
  302. /package/web/.next/standalone/web/.next/static/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_ssgManifest.js +0 -0
  303. /package/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_buildManifest.js +0 -0
  304. /package/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_clientMiddlewareManifest.json +0 -0
  305. /package/web/.next/static/{WCqUmRTRCgZqwBVGKQESX → eyxBRyMA8Zq36Pfen3Ylj}/_ssgManifest.js +0 -0
@@ -0,0 +1,1116 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import {
5
+ Loader2, Slack, Cloud, Calendar as CalendarIcon, Webhook as WebhookIcon,
6
+ Copy, RotateCw, Trash2, Plus, Check, Eye, EyeOff, Bot, Cpu, Key,
7
+ BookOpen, X as XIcon, ShieldCheck, Plug,
8
+ } from 'lucide-react';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Input } from '@/components/ui/input';
11
+ import { Label } from '@/components/ui/label';
12
+ import { getApiUrl, getConfig as getAppConfig } from '@/lib/config';
13
+ import { cn } from '@/lib/utils';
14
+
15
+ interface IntegrationsResponse {
16
+ channels: Array<{ id: string; canSend: boolean; label: string }>;
17
+ slack: {
18
+ configured: boolean;
19
+ botTokenSet: boolean;
20
+ signingSecretSet: boolean;
21
+ defaultOrchestratorName: string | null;
22
+ eventsUrl: string;
23
+ allowedUsers: string[];
24
+ allowedChannels: string[];
25
+ allowDmsFromAnyone: boolean;
26
+ deniedReplyEnabled: boolean;
27
+ deniedReplyTemplate: string;
28
+ };
29
+ cloudflared: { publicBaseUrl: string | null; hint: string };
30
+ cfAccess: { enabled: boolean; teamDomain: string | null; allowedEmails: string[] };
31
+ }
32
+
33
+ interface ScheduleRow {
34
+ id: string; name: string; cron: string; prompt: string;
35
+ replyChannel?: string; enabled: boolean; lastFiredAt?: string; createdAt: string;
36
+ }
37
+
38
+ interface WebhookRow {
39
+ id: string; name: string; token: string; url: string;
40
+ wake: 'now' | 'next'; template?: string;
41
+ hitCount: number; lastHitAt?: string; createdAt: string;
42
+ }
43
+
44
+ interface OrchestratorInfo {
45
+ id: string; name: string; personality: string; model: string; workingDirectory: string;
46
+ }
47
+
48
+ interface ApiKeyStatus {
49
+ provider: string; envVar: string; configured: boolean;
50
+ source: 'env' | 'storage' | 'none'; maskedKey: string | null;
51
+ }
52
+
53
+ import type { AppConfig } from '@/lib/config';
54
+
55
+ const api = (path: string) => `${getApiUrl()}${path}`;
56
+ async function jsonFetch<T>(path: string, init?: RequestInit): Promise<T> {
57
+ const res = await fetch(api(path), {
58
+ ...init,
59
+ headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
60
+ });
61
+ return res.json();
62
+ }
63
+
64
+ type TabId = 'general' | 'integrations' | 'mcp' | 'schedules' | 'webhooks' | 'models' | 'apikeys';
65
+
66
+ const TABS: Array<{ id: TabId; label: string; icon: React.ComponentType<{ className?: string }> }> = [
67
+ { id: 'general', label: 'General', icon: Bot },
68
+ { id: 'integrations', label: 'Integrations', icon: Slack },
69
+ { id: 'mcp', label: 'MCP', icon: Plug },
70
+ { id: 'schedules', label: 'Schedules', icon: CalendarIcon },
71
+ { id: 'webhooks', label: 'Webhooks', icon: WebhookIcon },
72
+ { id: 'models', label: 'Models', icon: Cpu },
73
+ { id: 'apikeys', label: 'API Keys', icon: Key },
74
+ ];
75
+
76
+ export default function SettingsPage() {
77
+ const [tab, setTab] = useState<TabId>('general');
78
+
79
+ return (
80
+ <div className="h-full flex bg-background">
81
+ {/* Left rail */}
82
+ <nav className="w-56 shrink-0 border-r border-border/60 p-3 overflow-y-auto">
83
+ <div className="px-2 py-2 mb-2">
84
+ <h1 className="text-base font-semibold">Settings</h1>
85
+ <p className="text-[11px] text-muted-foreground mt-0.5">Configure your orchestrator and channels.</p>
86
+ </div>
87
+ <ul className="space-y-0.5">
88
+ {TABS.map((t) => {
89
+ const Icon = t.icon;
90
+ const active = tab === t.id;
91
+ return (
92
+ <li key={t.id}>
93
+ <button
94
+ type="button"
95
+ onClick={() => setTab(t.id)}
96
+ className={cn(
97
+ 'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
98
+ active ? 'bg-accent text-foreground' : 'hover:bg-accent/60 text-foreground/80',
99
+ )}
100
+ >
101
+ <Icon className={cn('size-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground')} />
102
+ <span>{t.label}</span>
103
+ </button>
104
+ </li>
105
+ );
106
+ })}
107
+ </ul>
108
+ </nav>
109
+
110
+ {/* Right pane */}
111
+ <div className="flex-1 min-w-0 overflow-y-auto">
112
+ <div className="max-w-3xl mx-auto p-6">
113
+ {tab === 'general' && <GeneralSection />}
114
+ {tab === 'integrations' && <IntegrationsSection />}
115
+ {tab === 'mcp' && <McpSection />}
116
+ {tab === 'schedules' && <SchedulesSection />}
117
+ {tab === 'webhooks' && <WebhooksSection />}
118
+ {tab === 'models' && <ModelsSection />}
119
+ {tab === 'apikeys' && <ApiKeysSection />}
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ function SectionHeader({ title, description }: { title: string; description?: string }) {
127
+ return (
128
+ <div className="mb-4">
129
+ <h2 className="text-lg font-semibold">{title}</h2>
130
+ {description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // ============================================================
136
+ // General — orchestrator name + personality
137
+ // ============================================================
138
+
139
+ function GeneralSection() {
140
+ const [orc, setOrc] = useState<OrchestratorInfo | null>(null);
141
+ const [name, setName] = useState('');
142
+ const [personality, setPersonality] = useState('');
143
+ const [saving, setSaving] = useState(false);
144
+ const [saved, setSaved] = useState(false);
145
+
146
+ const refresh = useCallback(async () => {
147
+ const o = await jsonFetch<OrchestratorInfo>('/api/orchestrator');
148
+ setOrc(o);
149
+ setName(o.name || '');
150
+ setPersonality(o.personality || '');
151
+ }, []);
152
+
153
+ useEffect(() => { refresh(); }, [refresh]);
154
+
155
+ const save = async () => {
156
+ setSaving(true);
157
+ setSaved(false);
158
+ try {
159
+ await jsonFetch('/api/orchestrator', {
160
+ method: 'PATCH',
161
+ body: JSON.stringify({ name: name || undefined, personality }),
162
+ });
163
+ setSaved(true);
164
+ setTimeout(() => setSaved(false), 1500);
165
+ } finally { setSaving(false); }
166
+ };
167
+
168
+ if (!orc) {
169
+ return <div className="flex items-center justify-center py-12"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
170
+ }
171
+
172
+ const dirty = name !== orc.name || personality !== (orc.personality || '');
173
+
174
+ return (
175
+ <section>
176
+ <SectionHeader
177
+ title="Orchestrator"
178
+ description="Your supervisor agent's name and personality. The personality is appended to its system prompt."
179
+ />
180
+
181
+ <div className="space-y-4">
182
+ <div>
183
+ <Label className="text-sm font-medium">Agent name</Label>
184
+ <p className="text-xs text-muted-foreground mb-1.5">Shown in the sidebar and chat header.</p>
185
+ <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Orchestrator" className="max-w-md" />
186
+ </div>
187
+
188
+ <div>
189
+ <Label className="text-sm font-medium">Personality / persona</Label>
190
+ <p className="text-xs text-muted-foreground mb-1.5">
191
+ Optional. Free-form instructions appended to your orchestrator's system prompt every turn.
192
+ Examples: <em>"speak in haiku"</em>, <em>"be terse and professional"</em>, <em>"respond in Korean"</em>,
193
+ <em>"you are a senior engineer who values shipping over perfection"</em>.
194
+ </p>
195
+ <textarea
196
+ value={personality}
197
+ onChange={(e) => setPersonality(e.target.value)}
198
+ rows={6}
199
+ placeholder="(blank = default tone)"
200
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/40"
201
+ />
202
+ </div>
203
+
204
+ <div className="flex items-center gap-3 pt-1">
205
+ <Button onClick={save} disabled={!dirty || saving}>
206
+ {saving ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
207
+ Save
208
+ </Button>
209
+ {saved && <span className="text-xs text-emerald-500">Saved ✓</span>}
210
+ </div>
211
+
212
+ <div className="rounded-md border border-border/40 bg-muted/30 p-3 text-xs space-y-1">
213
+ <p><span className="text-muted-foreground">Session id:</span> <code>{orc.id}</code></p>
214
+ <p><span className="text-muted-foreground">Model:</span> <code>{orc.model}</code></p>
215
+ <p><span className="text-muted-foreground">Working directory:</span> <code className="break-all">{orc.workingDirectory}</code></p>
216
+ </div>
217
+ </div>
218
+ </section>
219
+ );
220
+ }
221
+
222
+ // ============================================================
223
+ // Integrations — Slack + Cloudflared
224
+ // ============================================================
225
+
226
+ function IntegrationsSection() {
227
+ const [data, setData] = useState<IntegrationsResponse | null>(null);
228
+ const refresh = useCallback(async () => {
229
+ setData(await jsonFetch<IntegrationsResponse>('/api/integrations'));
230
+ }, []);
231
+ useEffect(() => { refresh(); }, [refresh]);
232
+ if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
233
+ return (
234
+ <section className="space-y-4">
235
+ <SectionHeader title="Integrations" description="External channels the orchestrator can send and receive messages on." />
236
+ <SlackCard data={data} onChange={refresh} />
237
+ <CloudflaredCard data={data} />
238
+ </section>
239
+ );
240
+ }
241
+
242
+ function SlackCard({ data, onChange }: { data: IntegrationsResponse; onChange: () => void }) {
243
+ const [botToken, setBotToken] = useState('');
244
+ const [signingSecret, setSigningSecret] = useState('');
245
+ const [showToken, setShowToken] = useState(false);
246
+ const [saving, setSaving] = useState(false);
247
+ const [testChannel, setTestChannel] = useState('');
248
+ const [testResult, setTestResult] = useState<string | null>(null);
249
+ const [showSetup, setShowSetup] = useState(false);
250
+
251
+ const save = async () => {
252
+ setSaving(true);
253
+ try {
254
+ await jsonFetch('/api/integrations/slack', {
255
+ method: 'POST',
256
+ body: JSON.stringify({ botToken: botToken || undefined, signingSecret: signingSecret || undefined }),
257
+ });
258
+ setBotToken(''); setSigningSecret(''); onChange();
259
+ } finally { setSaving(false); }
260
+ };
261
+ const disconnect = async () => {
262
+ if (!confirm('Remove Slack credentials?')) return;
263
+ await jsonFetch('/api/integrations/slack', { method: 'DELETE' });
264
+ onChange();
265
+ };
266
+ const test = async () => {
267
+ if (!testChannel) return;
268
+ setTestResult('Sending…');
269
+ const r = await jsonFetch<{ ok: boolean; error?: string }>('/api/integrations/slack/test', {
270
+ method: 'POST', body: JSON.stringify({ channel: testChannel }),
271
+ });
272
+ setTestResult(r.ok ? 'Sent.' : `Failed: ${r.error}`);
273
+ };
274
+
275
+ return (
276
+ <>
277
+ <Card>
278
+ <CardHeader icon={<Slack className="size-4 text-[#4A154B] dark:text-pink-300" />} title="Slack"
279
+ status={data.slack.configured ? 'Configured' : 'Not configured'}
280
+ statusTone={data.slack.configured ? 'text-emerald-500' : 'text-muted-foreground'}
281
+ />
282
+ <p className="text-xs text-muted-foreground mb-3">
283
+ Lets the orchestrator receive @mentions and DMs in Slack and reply in-thread via the messenger tool.
284
+ </p>
285
+ <div className="flex gap-2 mb-3">
286
+ <Button size="sm" variant="outline" onClick={() => setShowSetup(true)}>
287
+ <BookOpen className="size-3.5 mr-1.5" />Setup instructions
288
+ </Button>
289
+ </div>
290
+ <div className="space-y-2">
291
+ <div>
292
+ <Label className="text-xs">Bot token (xoxb-...)</Label>
293
+ <div className="flex gap-2">
294
+ <Input type={showToken ? 'text' : 'password'} value={botToken} onChange={(e) => setBotToken(e.target.value)}
295
+ placeholder={data.slack.botTokenSet ? '(set)' : 'xoxb-...'} className="text-xs" />
296
+ <Button variant="outline" size="icon" onClick={() => setShowToken((v) => !v)}>
297
+ {showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
298
+ </Button>
299
+ </div>
300
+ </div>
301
+ <div>
302
+ <Label className="text-xs">Signing secret</Label>
303
+ <Input type="password" value={signingSecret} onChange={(e) => setSigningSecret(e.target.value)}
304
+ placeholder={data.slack.signingSecretSet ? '(set)' : '...'} className="text-xs" />
305
+ </div>
306
+ <div className="flex gap-2 pt-1">
307
+ <Button size="sm" onClick={save} disabled={saving || (!botToken && !signingSecret)}>
308
+ {saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
309
+ </Button>
310
+ {data.slack.configured && <Button size="sm" variant="outline" onClick={disconnect}>Disconnect</Button>}
311
+ </div>
312
+ </div>
313
+ <div className="mt-3 rounded border border-border/40 bg-background/50 p-2 text-xs space-y-1">
314
+ <p><strong>Events URL</strong> (paste into Slack &rarr; Event Subscriptions):</p>
315
+ <code className="block px-2 py-1 rounded bg-muted text-[11px] break-all">{data.slack.eventsUrl}</code>
316
+ <p className="text-muted-foreground">Subscribe to bot events: <code>app_mention</code>, <code>message.im</code>.</p>
317
+ </div>
318
+ {data.slack.configured && (
319
+ <div className="mt-3 flex gap-2 items-end">
320
+ <div className="flex-1">
321
+ <Label className="text-xs">Test post (channel id or #channel)</Label>
322
+ <Input value={testChannel} onChange={(e) => setTestChannel(e.target.value)} placeholder="C0123 or #general" className="text-xs" />
323
+ </div>
324
+ <Button size="sm" variant="outline" onClick={test} disabled={!testChannel}>Send test</Button>
325
+ {testResult && <span className="text-xs text-muted-foreground">{testResult}</span>}
326
+ </div>
327
+ )}
328
+ </Card>
329
+
330
+ {/* Access control card */}
331
+ <Card>
332
+ <CardHeader icon={<ShieldCheck className="size-4 text-amber-500" />} title="Slack access control"
333
+ status={accessSummary(data.slack)}
334
+ statusTone="text-muted-foreground"
335
+ />
336
+ <SlackAllowlistEditor data={data} onChange={onChange} />
337
+ </Card>
338
+
339
+ {showSetup && (
340
+ <SlackSetupModal
341
+ eventsUrl={data.slack.eventsUrl}
342
+ onClose={() => setShowSetup(false)}
343
+ />
344
+ )}
345
+ </>
346
+ );
347
+ }
348
+
349
+ function accessSummary(slack: IntegrationsResponse['slack']): string {
350
+ const parts: string[] = [];
351
+ if (slack.allowedUsers.length > 0) parts.push(`${slack.allowedUsers.length} user(s)`);
352
+ if (slack.allowedChannels.length > 0) parts.push(`${slack.allowedChannels.length} channel(s)`);
353
+ if (!slack.allowDmsFromAnyone) parts.push('DMs locked');
354
+ return parts.length === 0 ? 'Open to anyone' : parts.join(' · ');
355
+ }
356
+
357
+ function SlackAllowlistEditor({ data, onChange }: { data: IntegrationsResponse; onChange: () => void }) {
358
+ const [allowedUsers, setAllowedUsers] = useState(data.slack.allowedUsers.join(', '));
359
+ const [allowedChannels, setAllowedChannels] = useState(data.slack.allowedChannels.join(', '));
360
+ const [allowDmsFromAnyone, setAllowDmsFromAnyone] = useState(data.slack.allowDmsFromAnyone);
361
+ const [deniedReplyEnabled, setDeniedReplyEnabled] = useState(data.slack.deniedReplyEnabled);
362
+ const [deniedReplyTemplate, setDeniedReplyTemplate] = useState(data.slack.deniedReplyTemplate);
363
+ const [saving, setSaving] = useState(false);
364
+ const [saved, setSaved] = useState(false);
365
+
366
+ const dirty =
367
+ allowedUsers.trim() !== data.slack.allowedUsers.join(', ').trim() ||
368
+ allowedChannels.trim() !== data.slack.allowedChannels.join(', ').trim() ||
369
+ allowDmsFromAnyone !== data.slack.allowDmsFromAnyone ||
370
+ deniedReplyEnabled !== data.slack.deniedReplyEnabled ||
371
+ deniedReplyTemplate !== data.slack.deniedReplyTemplate;
372
+
373
+ const save = async () => {
374
+ setSaving(true); setSaved(false);
375
+ try {
376
+ const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
377
+ await jsonFetch('/api/integrations/slack', {
378
+ method: 'POST',
379
+ body: JSON.stringify({
380
+ allowedUsers: splitList(allowedUsers),
381
+ allowedChannels: splitList(allowedChannels),
382
+ allowDmsFromAnyone,
383
+ deniedReplyEnabled,
384
+ deniedReplyTemplate,
385
+ }),
386
+ });
387
+ onChange();
388
+ setSaved(true); setTimeout(() => setSaved(false), 1500);
389
+ } finally { setSaving(false); }
390
+ };
391
+
392
+ return (
393
+ <div className="space-y-3">
394
+ <p className="text-xs text-muted-foreground">
395
+ Empty lists = no allowlist (accept anyone Slack delivers an event for). When a list is set, only matching users / channels can talk to the orchestrator.
396
+ </p>
397
+
398
+ <div>
399
+ <Label className="text-xs">Allowed users (Slack user IDs)</Label>
400
+ <p className="text-[11px] text-muted-foreground mb-1">Comma or space separated. e.g. <code>U01ABCD</code>, <code>U09XYZ</code>. Leave blank to allow anyone.</p>
401
+ <Input value={allowedUsers} onChange={(e) => setAllowedUsers(e.target.value)} placeholder="U01..., U02..." className="text-xs font-mono" />
402
+ </div>
403
+
404
+ <div>
405
+ <Label className="text-xs">Allowed channels (Slack channel IDs)</Label>
406
+ <p className="text-[11px] text-muted-foreground mb-1">Channel IDs only (not <code>#name</code>). e.g. <code>C012345</code>. Affects @mentions; DMs use the toggle below.</p>
407
+ <Input value={allowedChannels} onChange={(e) => setAllowedChannels(e.target.value)} placeholder="C012345, C098765" className="text-xs font-mono" />
408
+ </div>
409
+
410
+ <label className="flex items-start gap-2 cursor-pointer">
411
+ <input
412
+ type="checkbox"
413
+ checked={allowDmsFromAnyone}
414
+ onChange={(e) => setAllowDmsFromAnyone(e.target.checked)}
415
+ className="mt-1"
416
+ />
417
+ <div className="text-xs">
418
+ <span className="font-medium">Allow DMs from anyone</span>
419
+ <p className="text-muted-foreground">
420
+ On: anyone who DMs your bot can talk to it. Off: only users in the allowlist above can DM.
421
+ </p>
422
+ </div>
423
+ </label>
424
+
425
+ <div className="border-t border-border/40 pt-3">
426
+ <label className="flex items-start gap-2 cursor-pointer">
427
+ <input
428
+ type="checkbox"
429
+ checked={deniedReplyEnabled}
430
+ onChange={(e) => setDeniedReplyEnabled(e.target.checked)}
431
+ className="mt-1"
432
+ />
433
+ <div className="text-xs">
434
+ <span className="font-medium">Auto-reply to denied users</span>
435
+ <p className="text-muted-foreground">
436
+ When someone is blocked by the rules above, post a canned reply (no AI) so they know why nothing happened. Posted in-thread for @mentions; in the DM for DMs.
437
+ </p>
438
+ </div>
439
+ </label>
440
+ <div className="mt-2 pl-6">
441
+ <Label className="text-xs">Reply template</Label>
442
+ <p className="text-[11px] text-muted-foreground mb-1">
443
+ Placeholders: <code className="px-1 rounded bg-muted">{'{user}'}</code> &rarr; mentions the requester, <code className="px-1 rounded bg-muted">{'{channel}'}</code> &rarr; mentions the channel.
444
+ </p>
445
+ <textarea
446
+ value={deniedReplyTemplate}
447
+ onChange={(e) => setDeniedReplyTemplate(e.target.value)}
448
+ rows={3}
449
+ disabled={!deniedReplyEnabled}
450
+ className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs disabled:opacity-50"
451
+ />
452
+ </div>
453
+ </div>
454
+
455
+ <div className="flex items-center gap-2 pt-1">
456
+ <Button size="sm" onClick={save} disabled={!dirty || saving}>
457
+ {saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
458
+ </Button>
459
+ {saved && <span className="text-xs text-emerald-500">Saved ✓</span>}
460
+ </div>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ function SlackSetupModal({ eventsUrl, onClose }: { eventsUrl: string; onClose: () => void }) {
466
+ const [copied, setCopied] = useState<string | null>(null);
467
+ const manifest = buildSlackManifest(eventsUrl);
468
+ const copy = async (key: string, text: string) => {
469
+ await navigator.clipboard.writeText(text);
470
+ setCopied(key); setTimeout(() => setCopied(null), 1500);
471
+ };
472
+ return (
473
+ <div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
474
+ <div className="bg-card rounded-lg border border-border max-w-2xl w-full max-h-[85vh] overflow-y-auto shadow-2xl" onClick={(e) => e.stopPropagation()}>
475
+ <div className="flex items-center justify-between px-5 py-3 border-b border-border/60 sticky top-0 bg-card">
476
+ <h2 className="text-base font-semibold flex items-center gap-2">
477
+ <Slack className="size-4 text-[#4A154B] dark:text-pink-300" />
478
+ Slack setup
479
+ </h2>
480
+ <Button size="icon" variant="ghost" onClick={onClose}><XIcon className="size-4" /></Button>
481
+ </div>
482
+ <div className="p-5 space-y-5 text-sm">
483
+ <Step n={1} title="Create a Slack app from manifest">
484
+ <p>Go to <a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noreferrer" className="text-primary underline">api.slack.com/apps</a>, click <strong>Create New App</strong> → <strong>From a manifest</strong>, pick your workspace, paste the YAML below, then create.</p>
485
+ <CopyBlock label="Slack manifest" value={manifest} onCopy={() => copy('manifest', manifest)} copied={copied === 'manifest'} />
486
+ </Step>
487
+
488
+ <Step n={2} title="Grab credentials">
489
+ <p>In your new app's <strong>Basic Information</strong> page, copy the <strong>Signing Secret</strong>. Then under <strong>OAuth &amp; Permissions</strong>, install the app to your workspace and copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).</p>
490
+ <p className="text-xs text-muted-foreground">Paste both into the Slack card above, then click Save.</p>
491
+ </Step>
492
+
493
+ <Step n={3} title="Verify the Events URL">
494
+ <p>Slack will challenge the URL automatically when you create the app from the manifest (it's pre-filled with your URL below). If it shows <strong>Verified</strong> you're done.</p>
495
+ <CopyBlock label="Events URL (already in the manifest)" value={eventsUrl} onCopy={() => copy('eventsUrl', eventsUrl)} copied={copied === 'eventsUrl'} mono />
496
+ <p className="text-[11px] text-muted-foreground">If verification fails, your sparkecoder isn't reachable from the public internet. Set up a cloudflared tunnel — see the Cloudflared card.</p>
497
+ </Step>
498
+
499
+ <Step n={4} title="Invite the bot to channels">
500
+ <p>In Slack: <code>/invite @your-bot-name</code> in any channel you want it to respond in. (DMs work without an invite.)</p>
501
+ </Step>
502
+
503
+ <Step n={5} title="Lock it down (optional but recommended)">
504
+ <p>By default <strong>anyone</strong> in your workspace can DM the bot or mention it in any channel it's in. Use the <strong>Slack access control</strong> section to limit access to specific Slack user IDs and channel IDs.</p>
505
+ <p className="text-[11px] text-muted-foreground">Find a user ID: click their name in Slack → ⋮ → Copy member ID. Find a channel ID: click the channel name → About → bottom of the panel.</p>
506
+ </Step>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ );
511
+ }
512
+
513
+ function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
514
+ return (
515
+ <div className="space-y-2">
516
+ <h3 className="font-medium flex items-center gap-2">
517
+ <span className="inline-flex items-center justify-center size-5 rounded-full bg-primary/15 text-primary text-[11px] font-semibold">{n}</span>
518
+ {title}
519
+ </h3>
520
+ <div className="pl-7 space-y-2 text-sm text-foreground/90">{children}</div>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ function CopyBlock({ label, value, onCopy, copied, mono }: { label: string; value: string; onCopy: () => void; copied: boolean; mono?: boolean }) {
526
+ return (
527
+ <div className="rounded border border-border/60 bg-background/60 overflow-hidden">
528
+ <div className="px-3 py-1.5 flex items-center justify-between border-b border-border/40 bg-muted/40">
529
+ <span className="text-[11px] text-muted-foreground">{label}</span>
530
+ <Button size="sm" variant="ghost" onClick={onCopy} className="h-6 text-[11px]">
531
+ {copied ? <Check className="size-3 mr-1" /> : <Copy className="size-3 mr-1" />}
532
+ {copied ? 'Copied' : 'Copy'}
533
+ </Button>
534
+ </div>
535
+ <pre className={cn('px-3 py-2 text-[11px] overflow-x-auto whitespace-pre-wrap break-all', mono && 'font-mono')}>{value}</pre>
536
+ </div>
537
+ );
538
+ }
539
+
540
+ function buildSlackManifest(eventsUrl: string): string {
541
+ // Trim trailing whitespace; Slack rejects manifests with weird indents.
542
+ return `display_information:
543
+ name: sparkecoder
544
+ description: Your orchestrator agent. Spawns worker agents, posts updates, answers questions.
545
+ background_color: "#1f1f24"
546
+ features:
547
+ bot_user:
548
+ display_name: sparkecoder
549
+ always_online: true
550
+ oauth_config:
551
+ scopes:
552
+ bot:
553
+ - app_mentions:read
554
+ - channels:history
555
+ - chat:write
556
+ - groups:history
557
+ - im:history
558
+ - im:read
559
+ - im:write
560
+ - users:read
561
+ settings:
562
+ event_subscriptions:
563
+ request_url: ${eventsUrl}
564
+ bot_events:
565
+ - app_mention
566
+ - message.im
567
+ interactivity:
568
+ is_enabled: false
569
+ org_deploy_enabled: false
570
+ socket_mode_enabled: false
571
+ token_rotation_enabled: false
572
+ `;
573
+ }
574
+
575
+ function CloudflaredCard({ data }: { data: IntegrationsResponse }) {
576
+ return (
577
+ <Card>
578
+ <CardHeader icon={<Cloud className="size-4 text-orange-500" />} title="Cloudflared tunnel"
579
+ status={data.cloudflared.publicBaseUrl ? 'Hostname set' : 'Local-only'}
580
+ statusTone={data.cloudflared.publicBaseUrl ? 'text-emerald-500' : 'text-muted-foreground'}
581
+ />
582
+ <p className="text-xs text-muted-foreground mb-2">
583
+ Required to receive Slack events and webhook hits from the public internet. Run{' '}
584
+ <code className="px-1 rounded bg-muted text-[11px]">sparkecoder cloudflared-setup</code> for a copy-paste recipe.
585
+ </p>
586
+ {data.cloudflared.publicBaseUrl && (
587
+ <div className="text-xs">
588
+ <span className="text-muted-foreground">Public base: </span>
589
+ <code className="px-1 rounded bg-muted">{data.cloudflared.publicBaseUrl}</code>
590
+ </div>
591
+ )}
592
+ {data.cfAccess.enabled && (
593
+ <div className="mt-2 text-xs">
594
+ <span className="text-muted-foreground">CF Access: </span><span className="text-emerald-500">enabled</span>
595
+ {data.cfAccess.allowedEmails.length > 0 && (
596
+ <span className="text-muted-foreground"> · {data.cfAccess.allowedEmails.length} allowed email(s)</span>
597
+ )}
598
+ </div>
599
+ )}
600
+ </Card>
601
+ );
602
+ }
603
+
604
+ // ============================================================
605
+ // Schedules
606
+ // ============================================================
607
+
608
+ function SchedulesSection() {
609
+ const [rows, setRows] = useState<ScheduleRow[]>([]);
610
+ const [adding, setAdding] = useState(false);
611
+ const refresh = useCallback(async () => {
612
+ const r = await jsonFetch<{ schedules: ScheduleRow[] }>('/api/schedules');
613
+ setRows(r.schedules || []);
614
+ }, []);
615
+ useEffect(() => { refresh(); }, [refresh]);
616
+ return (
617
+ <section>
618
+ <div className="flex items-center justify-between mb-3">
619
+ <SectionHeader title="Schedules" description="Recurring prompts that wake the orchestrator on a cron." />
620
+ <Button size="sm" variant="outline" onClick={() => setAdding((v) => !v)}><Plus className="size-3.5 mr-1" />New</Button>
621
+ </div>
622
+ {adding && <ScheduleForm onCancel={() => setAdding(false)} onSaved={() => { setAdding(false); refresh(); }} />}
623
+ {rows.length === 0 ? (
624
+ <p className="text-xs text-muted-foreground italic px-1">No schedules yet.</p>
625
+ ) : (
626
+ <div className="space-y-2">{rows.map((r) => <ScheduleRowView key={r.id} row={r} onChange={refresh} />)}</div>
627
+ )}
628
+ </section>
629
+ );
630
+ }
631
+
632
+ function ScheduleForm({ initial, onCancel, onSaved }: { initial?: Partial<ScheduleRow>; onCancel: () => void; onSaved: () => void }) {
633
+ const [name, setName] = useState(initial?.name || '');
634
+ const [cron, setCron] = useState(initial?.cron || '0 9 * * 1-5');
635
+ const [prompt, setPrompt] = useState(initial?.prompt || '');
636
+ const [replyChannel, setReplyChannel] = useState(initial?.replyChannel || '');
637
+ const [saving, setSaving] = useState(false);
638
+ const save = async () => {
639
+ if (!name || !cron || !prompt) return;
640
+ setSaving(true);
641
+ try {
642
+ if (initial?.id) {
643
+ await jsonFetch(`/api/schedules/${initial.id}`, { method: 'PATCH', body: JSON.stringify({ name, cron, prompt, replyChannel: replyChannel || undefined }) });
644
+ } else {
645
+ await jsonFetch('/api/schedules', { method: 'POST', body: JSON.stringify({ name, cron, prompt, replyChannel: replyChannel || undefined }) });
646
+ }
647
+ onSaved();
648
+ } finally { setSaving(false); }
649
+ };
650
+ return (
651
+ <div className="rounded-lg border border-border/60 bg-card/40 p-3 mb-2 space-y-2">
652
+ <div className="grid grid-cols-2 gap-2">
653
+ <div><Label className="text-xs">Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="standup-9am" className="text-xs" /></div>
654
+ <div><Label className="text-xs">Cron (5 fields)</Label><Input value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 9 * * 1-5" className="text-xs font-mono" /></div>
655
+ </div>
656
+ <div><Label className="text-xs">Prompt</Label><Input value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Summarize yesterday's git activity" className="text-xs" /></div>
657
+ <div><Label className="text-xs">Default reply channel (optional)</Label><Input value={replyChannel} onChange={(e) => setReplyChannel(e.target.value)} placeholder="slack:#standup" className="text-xs" /></div>
658
+ <div className="flex gap-2 pt-1">
659
+ <Button size="sm" onClick={save} disabled={saving || !name || !cron || !prompt}>{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}</Button>
660
+ <Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
661
+ </div>
662
+ </div>
663
+ );
664
+ }
665
+
666
+ function ScheduleRowView({ row, onChange }: { row: ScheduleRow; onChange: () => void }) {
667
+ const toggle = async () => { await jsonFetch(`/api/schedules/${row.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !row.enabled }) }); onChange(); };
668
+ const remove = async () => { if (!confirm(`Delete schedule "${row.name}"?`)) return; await jsonFetch(`/api/schedules/${row.id}`, { method: 'DELETE' }); onChange(); };
669
+ return (
670
+ <div className="rounded-md border border-border/60 bg-background/50 p-3 flex items-start gap-3">
671
+ <div className="flex-1 min-w-0">
672
+ <div className="flex items-center gap-2"><span className="text-sm font-medium">{row.name}</span>{!row.enabled && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">paused</span>}</div>
673
+ <code className="text-[11px] text-muted-foreground font-mono">{row.cron}</code>
674
+ <p className="text-xs mt-1 line-clamp-2 text-foreground/80">{row.prompt}</p>
675
+ {row.replyChannel && <p className="text-[10px] text-muted-foreground mt-0.5">replyChannel: {row.replyChannel}</p>}
676
+ {row.lastFiredAt && <p className="text-[10px] text-muted-foreground mt-0.5">last fired {new Date(row.lastFiredAt).toLocaleString()}</p>}
677
+ </div>
678
+ <div className="flex flex-col gap-1">
679
+ <Button size="sm" variant="outline" onClick={toggle}>{row.enabled ? 'Pause' : 'Resume'}</Button>
680
+ <Button size="sm" variant="ghost" onClick={remove} className="text-rose-500 hover:text-rose-600"><Trash2 className="size-3.5" /></Button>
681
+ </div>
682
+ </div>
683
+ );
684
+ }
685
+
686
+ // ============================================================
687
+ // Webhooks
688
+ // ============================================================
689
+
690
+ function WebhooksSection() {
691
+ const [rows, setRows] = useState<WebhookRow[]>([]);
692
+ const [adding, setAdding] = useState(false);
693
+ const refresh = useCallback(async () => {
694
+ const r = await jsonFetch<{ webhooks: WebhookRow[] }>('/api/webhooks');
695
+ setRows(r.webhooks || []);
696
+ }, []);
697
+ useEffect(() => { refresh(); }, [refresh]);
698
+ return (
699
+ <section>
700
+ <div className="flex items-center justify-between mb-3">
701
+ <SectionHeader title="Webhooks" description="Custom token-protected URLs. POST any JSON to ping the orchestrator." />
702
+ <Button size="sm" variant="outline" onClick={() => setAdding((v) => !v)}><Plus className="size-3.5 mr-1" />New</Button>
703
+ </div>
704
+ {adding && <WebhookForm onCancel={() => setAdding(false)} onSaved={() => { setAdding(false); refresh(); }} />}
705
+ {rows.length === 0 ? <p className="text-xs text-muted-foreground italic px-1">No webhooks yet.</p>
706
+ : <div className="space-y-2">{rows.map((r) => <WebhookRowView key={r.id} row={r} onChange={refresh} />)}</div>}
707
+ </section>
708
+ );
709
+ }
710
+
711
+ function WebhookForm({ onCancel, onSaved }: { onCancel: () => void; onSaved: () => void }) {
712
+ const [name, setName] = useState('');
713
+ const [wake, setWake] = useState<'now' | 'next'>('now');
714
+ const [template, setTemplate] = useState('');
715
+ const [saving, setSaving] = useState(false);
716
+ const save = async () => {
717
+ if (!name) return;
718
+ setSaving(true);
719
+ try {
720
+ await jsonFetch('/api/webhooks', { method: 'POST', body: JSON.stringify({ name, wake, template: template || undefined }) });
721
+ onSaved();
722
+ } finally { setSaving(false); }
723
+ };
724
+ return (
725
+ <div className="rounded-lg border border-border/60 bg-card/40 p-3 mb-2 space-y-2">
726
+ <div className="grid grid-cols-2 gap-2">
727
+ <div><Label className="text-xs">Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="github-prs" className="text-xs" /></div>
728
+ <div><Label className="text-xs">Wake</Label>
729
+ <select value={wake} onChange={(e) => setWake(e.target.value as 'now' | 'next')} className="w-full text-xs h-9 rounded-md border border-input bg-background px-2">
730
+ <option value="now">now — ping orchestrator immediately</option>
731
+ <option value="next">next — add as context, fire later</option>
732
+ </select>
733
+ </div>
734
+ </div>
735
+ <div><Label className="text-xs">Template (optional)</Label>
736
+ <Input value={template} onChange={(e) => setTemplate(e.target.value)} placeholder="PR {{pull_request.title}} by {{sender.login}}" className="text-xs font-mono" />
737
+ </div>
738
+ <div className="flex gap-2 pt-1">
739
+ <Button size="sm" onClick={save} disabled={saving || !name}>{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Create'}</Button>
740
+ <Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
741
+ </div>
742
+ </div>
743
+ );
744
+ }
745
+
746
+ function WebhookRowView({ row, onChange }: { row: WebhookRow; onChange: () => void }) {
747
+ const [copied, setCopied] = useState(false);
748
+ const copy = async () => { await navigator.clipboard.writeText(row.url); setCopied(true); setTimeout(() => setCopied(false), 1500); };
749
+ const rotate = async () => { if (!confirm('Rotate the token?')) return; await jsonFetch(`/api/webhooks/${row.id}`, { method: 'PATCH', body: JSON.stringify({ rotateToken: true }) }); onChange(); };
750
+ const remove = async () => { if (!confirm(`Delete webhook "${row.name}"?`)) return; await jsonFetch(`/api/webhooks/${row.id}`, { method: 'DELETE' }); onChange(); };
751
+ return (
752
+ <div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
753
+ <div className="flex items-center gap-2">
754
+ <span className="text-sm font-medium">{row.name}</span>
755
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">wake={row.wake}</span>
756
+ <span className="ml-auto text-[11px] text-muted-foreground">{row.hitCount} hit{row.hitCount === 1 ? '' : 's'}</span>
757
+ </div>
758
+ <div className="flex gap-1 items-center">
759
+ <code className="flex-1 text-[11px] px-2 py-1 rounded bg-muted break-all">{row.url}</code>
760
+ <Button size="icon" variant="outline" onClick={copy}>{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}</Button>
761
+ <Button size="icon" variant="outline" onClick={rotate} title="Rotate token"><RotateCw className="size-3.5" /></Button>
762
+ <Button size="icon" variant="ghost" onClick={remove} className="text-rose-500 hover:text-rose-600"><Trash2 className="size-3.5" /></Button>
763
+ </div>
764
+ {row.template && <p className="text-[10px] text-muted-foreground font-mono">template: {row.template}</p>}
765
+ {row.lastHitAt && <p className="text-[10px] text-muted-foreground">last hit {new Date(row.lastHitAt).toLocaleString()}</p>}
766
+ </div>
767
+ );
768
+ }
769
+
770
+ // ============================================================
771
+ // Models — default model picker
772
+ // ============================================================
773
+
774
+ function ModelsSection() {
775
+ const [cfg, setCfg] = useState<AppConfig | null>(null);
776
+ useEffect(() => { (async () => { setCfg(await getAppConfig()); })(); }, []);
777
+ if (!cfg) return <div className="flex items-center justify-center py-12"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
778
+ const current = cfg.availableModels.find((m) => m.id === cfg.defaultModel);
779
+ return (
780
+ <section>
781
+ <SectionHeader title="Models" description="Default model used for new sessions (orchestrator + workers). You can override per-session from the chat header." />
782
+ <div className="rounded-lg border border-border/60 bg-card/40 p-3 max-w-lg">
783
+ <Label className="text-xs">Current default</Label>
784
+ <p className="text-sm mt-1">
785
+ <strong>{current?.name || cfg.defaultModel}</strong>
786
+ {current?.provider && <span className="text-muted-foreground"> — {current.provider}</span>}
787
+ </p>
788
+ <p className="text-[11px] text-muted-foreground mt-2">
789
+ To change the default, set <code className="px-1 rounded bg-muted">defaultModel</code> in your{' '}
790
+ <code className="px-1 rounded bg-muted">sparkecoder.config.json</code> and restart the server.
791
+ </p>
792
+ </div>
793
+
794
+ <div className="mt-4">
795
+ <Label className="text-xs uppercase tracking-wide text-muted-foreground">Available models</Label>
796
+ <div className="mt-2 rounded-lg border border-border/60 divide-y divide-border/40">
797
+ {cfg.availableModels.map((m) => (
798
+ <div key={m.id} className={cn('flex items-center justify-between px-3 py-2 text-sm', m.id === cfg.defaultModel && 'bg-primary/5')}>
799
+ <div>
800
+ <span className="font-medium">{m.name}</span>
801
+ <span className="text-muted-foreground"> — {m.provider}</span>
802
+ {m.id === cfg.defaultModel && <span className="ml-2 text-[10px] uppercase tracking-wide text-primary">default</span>}
803
+ </div>
804
+ <code className="text-[10px] text-muted-foreground font-mono">{m.id}</code>
805
+ </div>
806
+ ))}
807
+ </div>
808
+ </div>
809
+ </section>
810
+ );
811
+ }
812
+
813
+ // ============================================================
814
+ // API Keys — provider keys
815
+ // ============================================================
816
+
817
+ function ApiKeysSection() {
818
+ const [keys, setKeys] = useState<ApiKeyStatus[]>([]);
819
+ const refresh = useCallback(async () => {
820
+ const r = await jsonFetch<{ providers: ApiKeyStatus[] }>('/health/api-keys');
821
+ setKeys(r.providers || []);
822
+ }, []);
823
+ useEffect(() => { refresh(); }, [refresh]);
824
+ return (
825
+ <section>
826
+ <SectionHeader title="API Keys" description="Provider credentials used for AI inference (Vercel AI Gateway etc.). Keys are stored locally." />
827
+ <div className="space-y-2">
828
+ {keys.map((k) => <ApiKeyRow key={k.provider} keyStatus={k} onChange={refresh} />)}
829
+ {keys.length === 0 && <p className="text-xs text-muted-foreground italic px-1">No providers configured.</p>}
830
+ </div>
831
+ </section>
832
+ );
833
+ }
834
+
835
+ function ApiKeyRow({ keyStatus, onChange }: { keyStatus: ApiKeyStatus; onChange: () => void }) {
836
+ const [editing, setEditing] = useState(false);
837
+ const [value, setValue] = useState('');
838
+ const [show, setShow] = useState(false);
839
+ const [saving, setSaving] = useState(false);
840
+ const save = async () => {
841
+ if (!value.trim()) return;
842
+ setSaving(true);
843
+ try {
844
+ await jsonFetch('/health/api-keys', { method: 'POST', body: JSON.stringify({ provider: keyStatus.provider, apiKey: value.trim() }) });
845
+ setValue(''); setEditing(false); onChange();
846
+ } finally { setSaving(false); }
847
+ };
848
+ const remove = async () => {
849
+ if (!confirm(`Remove ${keyStatus.provider} API key?`)) return;
850
+ await fetch(api(`/health/api-keys/${keyStatus.provider}`), { method: 'DELETE' });
851
+ onChange();
852
+ };
853
+ return (
854
+ <div className="rounded-md border border-border/60 bg-background/50 p-3">
855
+ <div className="flex items-center gap-2">
856
+ <span className="text-sm font-medium capitalize">{keyStatus.provider}</span>
857
+ {keyStatus.configured && (
858
+ <span className={cn(
859
+ 'text-[10px] px-1.5 py-0.5 rounded font-medium',
860
+ keyStatus.source === 'env' ? 'bg-blue-500/20 text-blue-600 dark:text-blue-300' : 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-300',
861
+ )}>{keyStatus.source === 'env' ? 'from env' : 'saved'}</span>
862
+ )}
863
+ <span className="ml-auto text-[10px] text-muted-foreground font-mono">{keyStatus.envVar}</span>
864
+ </div>
865
+ {editing ? (
866
+ <div className="flex gap-2 mt-2">
867
+ <div className="relative flex-1">
868
+ <Input type={show ? 'text' : 'password'} value={value} onChange={(e) => setValue(e.target.value)}
869
+ placeholder="Paste your API key…" className="pr-8 text-xs font-mono" autoFocus />
870
+ <Button type="button" variant="ghost" size="icon" className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" onClick={() => setShow((v) => !v)}>
871
+ {show ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
872
+ </Button>
873
+ </div>
874
+ <Button size="sm" onClick={save} disabled={saving || !value.trim()}>{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}</Button>
875
+ <Button size="sm" variant="ghost" onClick={() => { setEditing(false); setValue(''); }}>Cancel</Button>
876
+ </div>
877
+ ) : (
878
+ <div className="flex items-center mt-1">
879
+ <p className="text-[11px] text-muted-foreground font-mono flex-1">{keyStatus.configured ? keyStatus.maskedKey : 'Not configured'}</p>
880
+ <Button size="sm" variant="ghost" onClick={() => setEditing(true)} className="h-7 text-xs">{keyStatus.configured ? 'Update' : 'Add'}</Button>
881
+ {keyStatus.configured && keyStatus.source === 'storage' && (
882
+ <Button size="icon" variant="ghost" onClick={remove} className="text-rose-500 hover:text-rose-600"><Trash2 className="size-3.5" /></Button>
883
+ )}
884
+ </div>
885
+ )}
886
+ </div>
887
+ );
888
+ }
889
+
890
+ // ============================================================
891
+ // MCP servers
892
+ // ============================================================
893
+
894
+ interface McpServerRow {
895
+ id: string;
896
+ name: string;
897
+ transport: 'http' | 'sse' | 'stdio';
898
+ url?: string;
899
+ headers?: Record<string, string>;
900
+ command?: string;
901
+ args?: string[];
902
+ enabled: boolean;
903
+ createdAt: string;
904
+ }
905
+
906
+ function McpSection() {
907
+ const [rows, setRows] = useState<McpServerRow[]>([]);
908
+ const [adding, setAdding] = useState(false);
909
+ const [loading, setLoading] = useState(true);
910
+ const refresh = useCallback(async () => {
911
+ setLoading(true);
912
+ try {
913
+ const r = await jsonFetch<{ servers: McpServerRow[] }>('/api/mcp');
914
+ setRows(r.servers || []);
915
+ } finally { setLoading(false); }
916
+ }, []);
917
+ useEffect(() => { refresh(); }, [refresh]);
918
+ return (
919
+ <section>
920
+ <div className="flex items-center justify-between mb-3">
921
+ <SectionHeader
922
+ title="MCP integrations"
923
+ description={'Model Context Protocol servers. Each adds tools to every agent under "mcp_<name>_<tool>" on the next turn.'}
924
+ />
925
+ <Button size="sm" variant="outline" onClick={() => setAdding((v) => !v)}>
926
+ <Plus className="size-3.5 mr-1" />New
927
+ </Button>
928
+ </div>
929
+ <p className="text-[11px] text-muted-foreground mb-3 max-w-2xl">
930
+ For OAuth-protected MCP servers, paste the access token as a header here:
931
+ <code className="px-1 rounded bg-muted ml-1">{"{ Authorization: 'Bearer <your-token>' }"}</code>.
932
+ For unauthed servers, leave headers empty. Use <strong>Test</strong> to confirm the server is reachable.
933
+ </p>
934
+ {adding && <McpForm onCancel={() => setAdding(false)} onSaved={() => { setAdding(false); refresh(); }} />}
935
+ {loading ? <div className="flex items-center justify-center py-8"><Loader2 className="size-4 animate-spin text-muted-foreground" /></div>
936
+ : rows.length === 0 ? <p className="text-xs text-muted-foreground italic px-1">No MCP servers configured.</p>
937
+ : <div className="space-y-2">{rows.map((r) => <McpRowView key={r.id} row={r} onChange={refresh} />)}</div>}
938
+ </section>
939
+ );
940
+ }
941
+
942
+ function McpForm({ initial, onCancel, onSaved }: { initial?: Partial<McpServerRow>; onCancel: () => void; onSaved: () => void }) {
943
+ const [name, setName] = useState(initial?.name || '');
944
+ const [transport, setTransport] = useState<'http' | 'sse' | 'stdio'>((initial?.transport as any) || 'http');
945
+ const [url, setUrl] = useState(initial?.url || '');
946
+ const [headersText, setHeadersText] = useState(initial?.headers ? JSON.stringify(initial.headers, null, 2) : '');
947
+ const [command, setCommand] = useState(initial?.command || '');
948
+ const [argsText, setArgsText] = useState(initial?.args?.join(' ') || '');
949
+ const [saving, setSaving] = useState(false);
950
+ const [error, setError] = useState<string | null>(null);
951
+
952
+ const save = async () => {
953
+ setError(null);
954
+ let headers: Record<string, string> | undefined;
955
+ if (headersText.trim()) {
956
+ try {
957
+ const parsed = JSON.parse(headersText);
958
+ if (parsed && typeof parsed === 'object') headers = parsed as Record<string, string>;
959
+ } catch {
960
+ setError('Headers must be valid JSON, e.g. {"Authorization":"Bearer xxx"}');
961
+ return;
962
+ }
963
+ }
964
+ setSaving(true);
965
+ try {
966
+ const body: any = { name, transport, enabled: true };
967
+ if (transport === 'http' || transport === 'sse') { body.url = url; if (headers) body.headers = headers; }
968
+ if (transport === 'stdio') { body.command = command; body.args = argsText.split(/\s+/).filter(Boolean); }
969
+ if (initial?.id) {
970
+ await jsonFetch(`/api/mcp/${initial.id}`, { method: 'PATCH', body: JSON.stringify(body) });
971
+ } else {
972
+ await jsonFetch('/api/mcp', { method: 'POST', body: JSON.stringify(body) });
973
+ }
974
+ onSaved();
975
+ } catch (err: any) {
976
+ setError(err?.message || 'Save failed');
977
+ } finally { setSaving(false); }
978
+ };
979
+
980
+ return (
981
+ <div className="rounded-lg border border-border/60 bg-card/40 p-3 mb-3 space-y-2">
982
+ <div className="grid grid-cols-3 gap-2">
983
+ <div className="col-span-2">
984
+ <Label className="text-xs">Name</Label>
985
+ <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="github" className="text-xs" />
986
+ <p className="text-[10px] text-muted-foreground mt-0.5">Used as a tool prefix (e.g. <code>mcp_github_search</code>).</p>
987
+ </div>
988
+ <div>
989
+ <Label className="text-xs">Transport</Label>
990
+ <select value={transport} onChange={(e) => setTransport(e.target.value as any)} className="w-full text-xs h-9 rounded-md border border-input bg-background px-2">
991
+ <option value="http">HTTP (recommended)</option>
992
+ <option value="sse">SSE</option>
993
+ <option value="stdio">stdio (local)</option>
994
+ </select>
995
+ </div>
996
+ </div>
997
+ {transport !== 'stdio' ? (
998
+ <>
999
+ <div>
1000
+ <Label className="text-xs">URL</Label>
1001
+ <Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://your-server.com/mcp" className="text-xs font-mono" />
1002
+ </div>
1003
+ <div>
1004
+ <Label className="text-xs">Headers (JSON, optional)</Label>
1005
+ <textarea value={headersText} onChange={(e) => setHeadersText(e.target.value)}
1006
+ rows={3} placeholder={'{ "Authorization": "Bearer xxx" }'}
1007
+ className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs font-mono" />
1008
+ </div>
1009
+ </>
1010
+ ) : (
1011
+ <>
1012
+ <div>
1013
+ <Label className="text-xs">Command</Label>
1014
+ <Input value={command} onChange={(e) => setCommand(e.target.value)} placeholder="node" className="text-xs font-mono" />
1015
+ </div>
1016
+ <div>
1017
+ <Label className="text-xs">Args (space separated)</Label>
1018
+ <Input value={argsText} onChange={(e) => setArgsText(e.target.value)} placeholder="src/stdio/dist/server.js" className="text-xs font-mono" />
1019
+ </div>
1020
+ </>
1021
+ )}
1022
+ {error && <p className="text-[11px] text-rose-500">{error}</p>}
1023
+ <div className="flex gap-2 pt-1">
1024
+ <Button size="sm" onClick={save} disabled={saving || !name || (transport !== 'stdio' ? !url : !command)}>
1025
+ {saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
1026
+ </Button>
1027
+ <Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
1028
+ </div>
1029
+ </div>
1030
+ );
1031
+ }
1032
+
1033
+ function McpRowView({ row, onChange }: { row: McpServerRow; onChange: () => void }) {
1034
+ const [testResult, setTestResult] = useState<null | { ok: boolean; error?: string; toolCount?: number; toolNames?: string[] }>(null);
1035
+ const [testing, setTesting] = useState(false);
1036
+ const [editing, setEditing] = useState(false);
1037
+ const toggle = async () => {
1038
+ await jsonFetch(`/api/mcp/${row.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !row.enabled }) });
1039
+ onChange();
1040
+ };
1041
+ const remove = async () => {
1042
+ if (!confirm(`Delete MCP server "${row.name}"?`)) return;
1043
+ await jsonFetch(`/api/mcp/${row.id}`, { method: 'DELETE' });
1044
+ onChange();
1045
+ };
1046
+ const test = async () => {
1047
+ setTesting(true); setTestResult(null);
1048
+ try {
1049
+ const r = await jsonFetch<any>(`/api/mcp/${row.id}/test`, { method: 'POST' });
1050
+ setTestResult(r);
1051
+ } finally { setTesting(false); }
1052
+ };
1053
+ if (editing) {
1054
+ return <McpForm initial={row} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); onChange(); }} />;
1055
+ }
1056
+ return (
1057
+ <div className="rounded-md border border-border/60 bg-background/50 p-3">
1058
+ <div className="flex items-center gap-2">
1059
+ <Plug className={cn('size-4', row.enabled ? 'text-emerald-500' : 'text-muted-foreground')} />
1060
+ <span className="text-sm font-medium">{row.name}</span>
1061
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{row.transport}</span>
1062
+ {!row.enabled && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">disabled</span>}
1063
+ <span className="ml-auto text-[11px] text-muted-foreground font-mono break-all">
1064
+ tool prefix: <code>mcp_{row.name}_*</code>
1065
+ </span>
1066
+ </div>
1067
+ <p className="text-[11px] text-muted-foreground mt-1 break-all">
1068
+ {row.transport === 'stdio' ? <>cmd: <code>{row.command} {row.args?.join(' ')}</code></> : <>url: <code>{row.url}</code></>}
1069
+ {row.headers && Object.keys(row.headers).length > 0 && (
1070
+ <span className="ml-2">headers: {Object.entries(row.headers).map(([k, v]) => `${k}: ${v}`).join(', ')}</span>
1071
+ )}
1072
+ </p>
1073
+ <div className="flex gap-1 mt-2">
1074
+ <Button size="sm" variant="outline" onClick={test} disabled={testing}>
1075
+ {testing ? <Loader2 className="size-3 animate-spin mr-1" /> : null}Test
1076
+ </Button>
1077
+ <Button size="sm" variant="outline" onClick={toggle}>{row.enabled ? 'Disable' : 'Enable'}</Button>
1078
+ <Button size="sm" variant="ghost" onClick={() => setEditing(true)}>Edit</Button>
1079
+ <Button size="sm" variant="ghost" onClick={remove} className="text-rose-500 hover:text-rose-600 ml-auto">
1080
+ <Trash2 className="size-3.5" />
1081
+ </Button>
1082
+ </div>
1083
+ {testResult && (
1084
+ <div className={cn('mt-2 rounded border p-2 text-[11px]', testResult.ok ? 'border-emerald-500/40 bg-emerald-500/5' : 'border-rose-500/40 bg-rose-500/5')}>
1085
+ {testResult.ok ? (
1086
+ <>
1087
+ <p className="text-emerald-500">✓ Connected — {testResult.toolCount} tool(s) discovered</p>
1088
+ {testResult.toolNames && testResult.toolNames.length > 0 && (
1089
+ <p className="text-muted-foreground font-mono mt-0.5 break-all">{testResult.toolNames.join(', ')}</p>
1090
+ )}
1091
+ </>
1092
+ ) : (
1093
+ <p className="text-rose-500">✗ {testResult.error}</p>
1094
+ )}
1095
+ </div>
1096
+ )}
1097
+ </div>
1098
+ );
1099
+ }
1100
+
1101
+ // ============================================================
1102
+ // Shared shell
1103
+ // ============================================================
1104
+
1105
+ function Card({ children }: { children: React.ReactNode }) {
1106
+ return <div className="rounded-lg border border-border/60 bg-card/40 p-4">{children}</div>;
1107
+ }
1108
+ function CardHeader({ icon, title, status, statusTone }: { icon: React.ReactNode; title: string; status: string; statusTone: string }) {
1109
+ return (
1110
+ <div className="flex items-center gap-2 mb-3">
1111
+ {icon}
1112
+ <h3 className="font-medium">{title}</h3>
1113
+ <span className={cn('ml-auto text-xs', statusTone)}>{status}</span>
1114
+ </div>
1115
+ );
1116
+ }