conductor-oss 0.2.17 → 0.2.19

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 (211) hide show
  1. package/dist/commands/dashboard.js +2 -2
  2. package/dist/commands/start.d.ts +3 -6
  3. package/dist/commands/start.d.ts.map +1 -1
  4. package/dist/commands/start.js +389 -220
  5. package/dist/commands/start.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +4 -6
  8. package/dist/index.js.map +1 -1
  9. package/native/conductor +0 -0
  10. package/node_modules/@conductor-oss/core/dist/session-manager.js +1 -1
  11. package/node_modules/@conductor-oss/core/dist/session-manager.js.map +1 -1
  12. package/node_modules/@conductor-oss/plugin-agent-amp/package.json +1 -1
  13. package/node_modules/@conductor-oss/plugin-agent-ccr/package.json +1 -1
  14. package/node_modules/@conductor-oss/plugin-agent-claude-code/package.json +1 -1
  15. package/node_modules/@conductor-oss/plugin-agent-codex/package.json +1 -1
  16. package/node_modules/@conductor-oss/plugin-agent-cursor-cli/package.json +1 -1
  17. package/node_modules/@conductor-oss/plugin-agent-droid/package.json +1 -1
  18. package/node_modules/@conductor-oss/plugin-agent-gemini/package.json +1 -1
  19. package/node_modules/@conductor-oss/plugin-agent-github-copilot/package.json +1 -1
  20. package/node_modules/@conductor-oss/plugin-agent-opencode/package.json +1 -1
  21. package/node_modules/@conductor-oss/plugin-agent-qwen-code/package.json +1 -1
  22. package/node_modules/@conductor-oss/plugin-mcp-server/package.json +1 -1
  23. package/node_modules/@conductor-oss/plugin-notifier-desktop/package.json +1 -1
  24. package/node_modules/@conductor-oss/plugin-notifier-discord/package.json +1 -1
  25. package/node_modules/@conductor-oss/plugin-runtime-tmux/package.json +1 -1
  26. package/node_modules/@conductor-oss/plugin-scm-github/package.json +1 -1
  27. package/node_modules/@conductor-oss/plugin-terminal-web/package.json +1 -1
  28. package/node_modules/@conductor-oss/plugin-tracker-github/package.json +1 -1
  29. package/node_modules/@conductor-oss/plugin-workspace-worktree/package.json +1 -1
  30. package/package.json +21 -22
  31. package/web/.next/standalone/packages/web/.next/BUILD_ID +1 -1
  32. package/web/.next/standalone/packages/web/.next/app-path-routes-manifest.json +0 -1
  33. package/web/.next/standalone/packages/web/.next/build-manifest.json +2 -2
  34. package/web/.next/standalone/packages/web/.next/prerender-manifest.json +3 -3
  35. package/web/.next/standalone/packages/web/.next/routes-manifest.json +0 -6
  36. package/web/.next/standalone/packages/web/.next/server/app/_global-error.html +2 -2
  37. package/web/.next/standalone/packages/web/.next/server/app/_global-error.rsc +1 -1
  38. package/web/.next/standalone/packages/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  39. package/web/.next/standalone/packages/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/web/.next/standalone/packages/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  41. package/web/.next/standalone/packages/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  42. package/web/.next/standalone/packages/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  43. package/web/.next/standalone/packages/web/.next/server/app/_not-found/page/server-reference-manifest.json +7 -7
  44. package/web/.next/standalone/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  45. package/web/.next/standalone/packages/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  46. package/web/.next/standalone/packages/web/.next/server/app/_not-found.html +1 -1
  47. package/web/.next/standalone/packages/web/.next/server/app/_not-found.rsc +3 -3
  48. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  49. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  50. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  51. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  52. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  53. package/web/.next/standalone/packages/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  54. package/web/.next/standalone/packages/web/.next/server/app/api/access/route.js.nft.json +1 -1
  55. package/web/.next/standalone/packages/web/.next/server/app/api/attachments/route.js.nft.json +1 -1
  56. package/web/.next/standalone/packages/web/.next/server/app/api/boards/route.js.nft.json +1 -1
  57. package/web/.next/standalone/packages/web/.next/server/app/api/config/route.js.nft.json +1 -1
  58. package/web/.next/standalone/packages/web/.next/server/app/api/context-files/route.js.nft.json +1 -1
  59. package/web/.next/standalone/packages/web/.next/server/app/api/events/route.js.nft.json +1 -1
  60. package/web/.next/standalone/packages/web/.next/server/app/api/filesystem/directory/route.js.nft.json +1 -1
  61. package/web/.next/standalone/packages/web/.next/server/app/api/github/repos/route.js.nft.json +1 -1
  62. package/web/.next/standalone/packages/web/.next/server/app/api/health/boards/route.js.nft.json +1 -1
  63. package/web/.next/standalone/packages/web/.next/server/app/api/health/sessions/route.js.nft.json +1 -1
  64. package/web/.next/standalone/packages/web/.next/server/app/api/notifications/route.js.nft.json +1 -1
  65. package/web/.next/standalone/packages/web/.next/server/app/api/preferences/route.js.nft.json +1 -1
  66. package/web/.next/standalone/packages/web/.next/server/app/api/repositories/route.js.nft.json +1 -1
  67. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/actions/route.js.nft.json +1 -1
  68. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/checks/route.js.nft.json +1 -1
  69. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/diff/route.js.nft.json +1 -1
  70. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/feed/route.js.nft.json +1 -1
  71. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/feedback/route.js.nft.json +1 -1
  72. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/files/route.js.nft.json +1 -1
  73. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/keys/route.js.nft.json +1 -1
  74. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/kill/route.js.nft.json +1 -1
  75. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/output/route.js.nft.json +1 -1
  76. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/output/stream/route.js.nft.json +1 -1
  77. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -1
  78. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  79. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/[id]/send/route.js.nft.json +1 -1
  80. package/web/.next/standalone/packages/web/.next/server/app/api/sessions/route.js.nft.json +1 -1
  81. package/web/.next/standalone/packages/web/.next/server/app/api/spawn/route.js.nft.json +1 -1
  82. package/web/.next/standalone/packages/web/.next/server/app/api/workspaces/branches/route.js.nft.json +1 -1
  83. package/web/.next/standalone/packages/web/.next/server/app/api/workspaces/route.js +4 -4
  84. package/web/.next/standalone/packages/web/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  85. package/web/.next/standalone/packages/web/.next/server/app/index.html +1 -1
  86. package/web/.next/standalone/packages/web/.next/server/app/index.rsc +4 -4
  87. package/web/.next/standalone/packages/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  88. package/web/.next/standalone/packages/web/.next/server/app/index.segments/_full.segment.rsc +4 -4
  89. package/web/.next/standalone/packages/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  90. package/web/.next/standalone/packages/web/.next/server/app/index.segments/_index.segment.rsc +3 -3
  91. package/web/.next/standalone/packages/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  92. package/web/.next/standalone/packages/web/.next/server/app/page/react-loadable-manifest.json +1 -1
  93. package/web/.next/standalone/packages/web/.next/server/app/page/server-reference-manifest.json +7 -7
  94. package/web/.next/standalone/packages/web/.next/server/app/page.js.nft.json +1 -1
  95. package/web/.next/standalone/packages/web/.next/server/app/page_client-reference-manifest.js +1 -1
  96. package/web/.next/standalone/packages/web/.next/server/app/sessions/[id]/page/server-reference-manifest.json +7 -7
  97. package/web/.next/standalone/packages/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  98. package/web/.next/standalone/packages/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  99. package/web/.next/standalone/packages/web/.next/server/app/sign-in/[[...sign-in]]/page/server-reference-manifest.json +7 -7
  100. package/web/.next/standalone/packages/web/.next/server/app/sign-in/[[...sign-in]]/page.js.nft.json +1 -1
  101. package/web/.next/standalone/packages/web/.next/server/app/sign-in/[[...sign-in]]/page_client-reference-manifest.js +1 -1
  102. package/web/.next/standalone/packages/web/.next/server/app/unlock/page/server-reference-manifest.json +7 -7
  103. package/web/.next/standalone/packages/web/.next/server/app/unlock/page.js.nft.json +1 -1
  104. package/web/.next/standalone/packages/web/.next/server/app/unlock/page_client-reference-manifest.js +1 -1
  105. package/web/.next/standalone/packages/web/.next/server/app-paths-manifest.json +0 -1
  106. package/web/.next/standalone/packages/web/.next/server/chunks/[root-of-the-server]__1ed2e6c1._.js +1 -1
  107. package/web/.next/standalone/packages/web/.next/server/chunks/[root-of-the-server]__3d6b30a3._.js +1 -1
  108. package/web/.next/standalone/packages/web/.next/server/chunks/[root-of-the-server]__a0b6570d._.js +3 -0
  109. package/web/.next/standalone/packages/web/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0e4dc4f7.js +2 -1
  110. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/{[root-of-the-server]__2814b563._.js → [root-of-the-server]__1c826f12._.js} +2 -2
  111. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__6622b514._.js +1 -1
  112. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__869d9ac0._.js +1 -1
  113. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__9dc23e5a._.js +1 -1
  114. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__b388693f._.js +1 -1
  115. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_0e1412de._.js +1 -1
  116. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/{_b0abbdd9._.js → _20a4007d._.js} +2 -2
  117. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_69e05fca._.js +1 -1
  118. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_80efe193._.js +1 -1
  119. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_b6d31783._.js +1 -1
  120. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/{_0973acf3._.js → _b88bcf2c._.js} +2 -2
  121. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_c0f0e227._.js +1 -1
  122. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/_f36ddaa9._.js +1 -1
  123. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/node_modules_270cb834._.js +1 -1
  124. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/{node_modules_@clerk_nextjs_dist_esm_app-router_ae92e1b6._.js → node_modules_@clerk_nextjs_dist_esm_app-router_2985ec6c._.js} +2 -2
  125. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/node_modules_@clerk_nextjs_dist_esm_app-router_ad33a435._.js +3 -0
  126. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/node_modules_f2ebd7a9._.js +1 -1
  127. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/packages_web_src_79316445._.js +1 -1
  128. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/packages_web_src_a078c137._.js +1 -1
  129. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/packages_web_src_app_page_tsx_cd282e82._.js +1 -1
  130. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/{packages_web_src_components_f2c9e753._.js → packages_web_src_components_6cec00fe._.js} +1 -1
  131. package/web/.next/standalone/packages/web/.next/server/functions-config-manifest.json +1 -2
  132. package/web/.next/standalone/packages/web/.next/server/pages/404.html +1 -1
  133. package/web/.next/standalone/packages/web/.next/server/pages/500.html +2 -2
  134. package/web/.next/standalone/packages/web/.next/server/server-reference-manifest.js +1 -1
  135. package/web/.next/standalone/packages/web/.next/server/server-reference-manifest.json +8 -8
  136. package/web/.next/standalone/packages/web/.next/static/chunks/062888342200567f.js +1 -0
  137. package/web/.next/standalone/packages/web/.next/static/chunks/695b7cb206c6dadd.js +1 -0
  138. package/web/.next/standalone/packages/web/.next/static/chunks/6b43741a27171ff7.js +1 -0
  139. package/web/.next/standalone/packages/web/.next/static/chunks/8221b78965a50858.js +1 -0
  140. package/web/.next/standalone/packages/web/.next/static/chunks/858f7ae5c23d7b37.js +1 -0
  141. package/web/.next/standalone/packages/web/.next/static/chunks/8de84e208e201d72.css +3 -0
  142. package/web/.next/standalone/packages/web/.next/static/chunks/{9785347bf1d88302.js → ad82e3dcd5fe1a50.js} +3 -3
  143. package/web/.next/standalone/packages/web/.next/static/chunks/c4ea57fb949fb623.js +1 -0
  144. package/web/.next/standalone/packages/web/.next/static/chunks/{4c566fd1e4a92935.js → d9d05e7b540400af.js} +1 -1
  145. package/web/.next/{static/chunks/719697e99b51d55b.js → standalone/packages/web/.next/static/chunks/f5d9ad0f62ede339.js} +1 -1
  146. package/web/.next/standalone/packages/web/src/app/page.tsx +3 -4555
  147. package/web/.next/standalone/packages/web/src/app/sessions/[id]/page.tsx +2 -115
  148. package/web/.next/standalone/packages/web/src/components/layout/AppShell.tsx +62 -2
  149. package/web/.next/standalone/packages/web/src/components/layout/TopBar.tsx +19 -19
  150. package/web/.next/standalone/packages/web/src/components/layout/WorkspaceSidebarPanel.tsx +68 -10
  151. package/web/.next/standalone/packages/web/src/features/dashboard/DashboardClient.tsx +4587 -0
  152. package/web/.next/standalone/packages/web/src/features/dashboard/components/WorkspaceOverview.tsx +296 -0
  153. package/web/.next/standalone/packages/web/src/features/sessions/SessionPageClient.tsx +135 -0
  154. package/web/.next/standalone/packages/web/src/hooks/useSessionFeed.ts +17 -13
  155. package/web/.next/standalone/packages/web/src/hooks/useSessions.ts +37 -7
  156. package/web/.next/standalone/packages/web/src/proxy.ts +1 -0
  157. package/web/.next/static/chunks/062888342200567f.js +1 -0
  158. package/web/.next/static/chunks/695b7cb206c6dadd.js +1 -0
  159. package/web/.next/static/chunks/6b43741a27171ff7.js +1 -0
  160. package/web/.next/static/chunks/8221b78965a50858.js +1 -0
  161. package/web/.next/static/chunks/858f7ae5c23d7b37.js +1 -0
  162. package/web/.next/static/chunks/8de84e208e201d72.css +3 -0
  163. package/web/.next/static/chunks/{9785347bf1d88302.js → ad82e3dcd5fe1a50.js} +3 -3
  164. package/web/.next/static/chunks/c4ea57fb949fb623.js +1 -0
  165. package/web/.next/static/chunks/{4c566fd1e4a92935.js → d9d05e7b540400af.js} +1 -1
  166. package/web/.next/{standalone/packages/web/.next/static/chunks/719697e99b51d55b.js → static/chunks/f5d9ad0f62ede339.js} +1 -1
  167. package/dist/commands/watch.d.ts +0 -12
  168. package/dist/commands/watch.d.ts.map +0 -1
  169. package/dist/commands/watch.js +0 -84
  170. package/dist/commands/watch.js.map +0 -1
  171. package/dist/commands/webhook.d.ts +0 -12
  172. package/dist/commands/webhook.d.ts.map +0 -1
  173. package/dist/commands/webhook.js +0 -59
  174. package/dist/commands/webhook.js.map +0 -1
  175. package/node_modules/@conductor-oss/plugin-webhook/dist/index.d.ts +0 -28
  176. package/node_modules/@conductor-oss/plugin-webhook/dist/index.d.ts.map +0 -1
  177. package/node_modules/@conductor-oss/plugin-webhook/dist/index.js +0 -295
  178. package/node_modules/@conductor-oss/plugin-webhook/dist/index.js.map +0 -1
  179. package/node_modules/@conductor-oss/plugin-webhook/package.json +0 -11
  180. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route/app-paths-manifest.json +0 -3
  181. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route/build-manifest.json +0 -11
  182. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route/server-reference-manifest.json +0 -4
  183. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route.js +0 -8
  184. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route.js.map +0 -5
  185. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route.js.nft.json +0 -1
  186. package/web/.next/standalone/packages/web/.next/server/app/api/agents/route_client-reference-manifest.js +0 -2
  187. package/web/.next/standalone/packages/web/.next/server/chunks/[root-of-the-server]__7633d324._.js +0 -4
  188. package/web/.next/standalone/packages/web/.next/server/chunks/[root-of-the-server]__bf8faac8._.js +0 -4
  189. package/web/.next/standalone/packages/web/.next/server/chunks/packages_web__next-internal_server_app_api_agents_route_actions_29063d1a.js +0 -3
  190. package/web/.next/standalone/packages/web/.next/server/chunks/ssr/node_modules_@clerk_nextjs_dist_esm_app-router_39a27288._.js +0 -3
  191. package/web/.next/standalone/packages/web/.next/static/chunks/1867dba46fcc022e.js +0 -1
  192. package/web/.next/standalone/packages/web/.next/static/chunks/995a5af4e8529901.js +0 -1
  193. package/web/.next/standalone/packages/web/.next/static/chunks/ab8ef6c7dfc78082.js +0 -1
  194. package/web/.next/standalone/packages/web/.next/static/chunks/b5e2d1ef92e508a0.js +0 -1
  195. package/web/.next/standalone/packages/web/.next/static/chunks/dc65fd7512517f7d.js +0 -1
  196. package/web/.next/standalone/packages/web/.next/static/chunks/e8283780c26eaa91.js +0 -1
  197. package/web/.next/standalone/packages/web/.next/static/chunks/fe557eb4039d8a8d.css +0 -3
  198. package/web/.next/standalone/packages/web/src/app/api/agents/route.ts +0 -223
  199. package/web/.next/static/chunks/1867dba46fcc022e.js +0 -1
  200. package/web/.next/static/chunks/995a5af4e8529901.js +0 -1
  201. package/web/.next/static/chunks/ab8ef6c7dfc78082.js +0 -1
  202. package/web/.next/static/chunks/b5e2d1ef92e508a0.js +0 -1
  203. package/web/.next/static/chunks/dc65fd7512517f7d.js +0 -1
  204. package/web/.next/static/chunks/e8283780c26eaa91.js +0 -1
  205. package/web/.next/static/chunks/fe557eb4039d8a8d.css +0 -3
  206. /package/web/.next/standalone/packages/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_buildManifest.js +0 -0
  207. /package/web/.next/standalone/packages/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_clientMiddlewareManifest.json +0 -0
  208. /package/web/.next/standalone/packages/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_ssgManifest.js +0 -0
  209. /package/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_buildManifest.js +0 -0
  210. /package/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_clientMiddlewareManifest.json +0 -0
  211. /package/web/.next/static/{U7zhuWy5hxiSO9ZXtDOfi → UGPSYGs8x8jahGnVodP_c}/_ssgManifest.js +0 -0
@@ -0,0 +1,4587 @@
1
+ "use client";
2
+
3
+ import dynamic from "next/dynamic";
4
+ import { type FormEvent, memo, useCallback, useEffect, useMemo, useState } from "react";
5
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
6
+ import {
7
+ getAgentModelCatalog,
8
+ resolveAgentModelAccess,
9
+ supportsAgentModelSelection,
10
+ type AgentModelOption,
11
+ type AgentReasoningOption,
12
+ type DashboardRole,
13
+ type ModelAccessPreferences,
14
+ type TrustedHeaderAccessProvider,
15
+ } from "@conductor-oss/core/types";
16
+ import type { IconType } from "react-icons";
17
+ import { SiNotion, SiObsidian } from "react-icons/si";
18
+ import { VscVscode } from "react-icons/vsc";
19
+ import {
20
+ Bot,
21
+ BookText,
22
+ Building2,
23
+ ChevronsRight,
24
+ Check,
25
+ ChevronDown,
26
+ Copy,
27
+ Eye,
28
+ FolderOpen,
29
+ FolderGit2,
30
+ FolderKanban,
31
+ Github,
32
+ Hand,
33
+ List,
34
+ Loader2,
35
+ Paperclip,
36
+ PlugZap,
37
+ RefreshCcw,
38
+ Search,
39
+ SlidersHorizontal,
40
+ Settings2,
41
+ type LucideIcon,
42
+ Volume2,
43
+ VolumeX,
44
+ X,
45
+ } from "lucide-react";
46
+ import type { DashboardSession } from "@/lib/types";
47
+ import { normalizeAgentName } from "@/lib/agentUtils";
48
+ import { useSessions } from "@/hooks/useSessions";
49
+ import { useConfig, type ConfigProject } from "@/hooks/useConfig";
50
+ import { useAgents } from "@/hooks/useAgents";
51
+ import { AppShell } from "@/components/layout/AppShell";
52
+ import { TopBar } from "@/components/layout/TopBar";
53
+ import { AgentTileIcon } from "@/components/AgentTileIcon";
54
+ import { WorkspaceSidebarPanel } from "@/components/layout/WorkspaceSidebarPanel";
55
+ import { normalizeModelAccessPreferences } from "@/lib/modelAccess";
56
+ import {
57
+ getRuntimeCatalogDefaultModelForAccess,
58
+ getRuntimeCatalogDefaultReasoning,
59
+ getRuntimeCatalogModelsForAccess,
60
+ getRuntimeCatalogReasoningOptions,
61
+ type RuntimeAgentModelCatalog,
62
+ } from "@/lib/runtimeAgentModelsShared";
63
+ import { WorkspaceOverview } from "@/features/dashboard/components/WorkspaceOverview";
64
+
65
+ const EXECUTOR_ORDER = [
66
+ "codex",
67
+ "gemini",
68
+ "qwen-code",
69
+ "droid",
70
+ "claude-code",
71
+ "amp",
72
+ "opencode",
73
+ "github-copilot",
74
+ "cursor-cli",
75
+ "ccr",
76
+ ];
77
+
78
+ const DEFAULT_AGENT = "claude-code";
79
+
80
+ const EXECUTOR_LABELS: Record<string, string> = {
81
+ codex: "Codex",
82
+ gemini: "Gemini",
83
+ "qwen-code": "Qwen Code",
84
+ droid: "Droid",
85
+ "claude-code": "Claude Code",
86
+ amp: "Amp",
87
+ opencode: "Opencode",
88
+ "github-copilot": "Copilot",
89
+ "cursor-cli": "Cursor Agent",
90
+ ccr: "CCR",
91
+ };
92
+
93
+ const AGENT_SETUP_URLS: Record<string, string> = {
94
+ "claude-code": "https://claude.ai/",
95
+ codex: "https://chatgpt.com/codex",
96
+ gemini: "https://aistudio.google.com/",
97
+ "qwen-code": "https://chat.qwen.ai/",
98
+ opencode: "https://opencode.ai/",
99
+ "github-copilot": "https://github.com/settings/copilot",
100
+ };
101
+
102
+ const SessionDetail = dynamic(
103
+ () => import("@/components/sessions/SessionDetail").then((mod) => mod.SessionDetail),
104
+ {
105
+ loading: () => (
106
+ <div className="flex h-full items-center justify-center text-[13px] text-[var(--vk-text-muted)]">
107
+ Loading session...
108
+ </div>
109
+ ),
110
+ },
111
+ );
112
+
113
+ const WorkspaceKanban = dynamic(
114
+ () => import("@/components/board/WorkspaceKanban").then((mod) => mod.WorkspaceKanban),
115
+ {
116
+ loading: () => (
117
+ <div className="flex h-full items-center justify-center text-[13px] text-[var(--vk-text-muted)]">
118
+ Loading board...
119
+ </div>
120
+ ),
121
+ },
122
+ );
123
+
124
+ function getAgentLabel(value: string): string {
125
+ const normalized = normalizeAgentName(value);
126
+ if (EXECUTOR_LABELS[normalized]) return EXECUTOR_LABELS[normalized];
127
+ return value
128
+ .split(/[-_\s]+/g)
129
+ .filter(Boolean)
130
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
131
+ .join(" ");
132
+ }
133
+
134
+ function formatCurrentModelLabel(agentName: string, modelId: string): string {
135
+ const normalizedModel = modelId.trim();
136
+ const normalizedAgent = normalizeAgentName(agentName);
137
+ if (!normalizedModel) return normalizedModel;
138
+
139
+ if (normalizedAgent === "claude-code") {
140
+ const lower = normalizedModel.toLowerCase();
141
+ if (lower === "opus") return "Claude Opus";
142
+ if (lower === "sonnet") return "Claude Sonnet";
143
+ if (lower === "haiku") return "Claude Haiku";
144
+ const match = lower.match(/^claude-(sonnet|opus|haiku)-(\d+)-(\d+)(?:-\d{8})?$/);
145
+ if (match) {
146
+ const family = match[1];
147
+ return `Claude ${family[0]?.toUpperCase() + family.slice(1)} ${match[2]}.${match[3]}`;
148
+ }
149
+ }
150
+
151
+ return normalizedModel
152
+ .split(/[-_]+/g)
153
+ .filter(Boolean)
154
+ .map((segment) => {
155
+ const lower = segment.toLowerCase();
156
+ if (lower === "gpt") return "GPT";
157
+ if (/^\d+(?:\.\d+)?$/.test(segment)) return segment;
158
+ return segment[0]?.toUpperCase() + segment.slice(1);
159
+ })
160
+ .join("-");
161
+ }
162
+
163
+ type NewWorkspacePayload = {
164
+ mode: "git" | "local";
165
+ projectId?: string;
166
+ agent: string;
167
+ defaultBranch: string;
168
+ useWorktree?: boolean;
169
+ gitUrl?: string;
170
+ path?: string;
171
+ initializeGit?: boolean;
172
+ };
173
+
174
+ type CreatePermissionMode = "default" | "auto" | "ask" | "plan";
175
+
176
+ type CreateSessionOptions = {
177
+ projectId?: string;
178
+ branch?: string;
179
+ baseBranch?: string;
180
+ useWorktree?: boolean;
181
+ permissionMode?: CreatePermissionMode;
182
+ issueId?: string;
183
+ };
184
+
185
+ type LinkedBoardTask = {
186
+ id: string;
187
+ text: string;
188
+ taskRef: string | null;
189
+ type: string | null;
190
+ priority: string | null;
191
+ };
192
+
193
+ type LinkedBoardResponse = {
194
+ columns?: Array<{
195
+ tasks?: LinkedBoardTask[];
196
+ }>;
197
+ };
198
+
199
+ type GitHubRepo = {
200
+ name: string;
201
+ fullName: string;
202
+ httpsUrl: string;
203
+ sshUrl: string;
204
+ defaultBranch: string;
205
+ private: boolean;
206
+ };
207
+
208
+ type DirectoryEntry = {
209
+ name: string;
210
+ path: string;
211
+ isDirectory: boolean;
212
+ isGitRepo: boolean;
213
+ };
214
+
215
+ type PreferencesPayload = {
216
+ onboardingAcknowledged: boolean;
217
+ codingAgent: string;
218
+ ide: string;
219
+ remoteSshHost: string;
220
+ remoteSshUser: string;
221
+ markdownEditor: string;
222
+ modelAccess: ModelAccessPreferences;
223
+ notifications: {
224
+ soundEnabled: boolean;
225
+ soundFile: string | null;
226
+ };
227
+ };
228
+
229
+ type AccessIdentitySummary = {
230
+ authenticated: boolean;
231
+ role: DashboardRole | null;
232
+ email: string | null;
233
+ provider: string | null;
234
+ };
235
+
236
+ function getLinkedTaskValue(task: LinkedBoardTask): string {
237
+ return task.taskRef?.trim() || task.id;
238
+ }
239
+
240
+ function getLinkedTaskTitle(text: string): string {
241
+ const [title] = text.split(" - ");
242
+ return (title ?? text).trim();
243
+ }
244
+
245
+ type AccessSettingsPayload = {
246
+ requireAuth: boolean;
247
+ defaultRole: DashboardRole;
248
+ trustedHeaders: {
249
+ enabled: boolean;
250
+ provider: TrustedHeaderAccessProvider;
251
+ emailHeader: string;
252
+ jwtHeader: string;
253
+ teamDomain: string;
254
+ audience: string;
255
+ };
256
+ roles: {
257
+ viewers: string;
258
+ operators: string;
259
+ admins: string;
260
+ viewerDomains: string;
261
+ operatorDomains: string;
262
+ adminDomains: string;
263
+ };
264
+ current: AccessIdentitySummary;
265
+ };
266
+
267
+ type RepositoryPathHealth = {
268
+ exists: boolean;
269
+ isGitRepository: boolean;
270
+ suggestedPath: string | null;
271
+ };
272
+
273
+ type RepositorySettingsPayload = {
274
+ id: string;
275
+ displayName: string;
276
+ repo: string;
277
+ path: string;
278
+ agent: string;
279
+ agentModel: string;
280
+ agentReasoningEffort: string;
281
+ workspaceMode: string;
282
+ runtimeMode: string;
283
+ scmMode: string;
284
+ defaultWorkingDirectory: string;
285
+ defaultBranch: string;
286
+ devServerScript: string;
287
+ setupScript: string;
288
+ runSetupInParallel: boolean;
289
+ cleanupScript: string;
290
+ archiveScript: string;
291
+ copyFiles: string;
292
+ pathHealth: RepositoryPathHealth;
293
+ };
294
+
295
+ type ModelSelectionState = {
296
+ catalogModel: string;
297
+ customModel: string;
298
+ reasoningEffort: string;
299
+ };
300
+
301
+ type AgentSetupState = {
302
+ name: string;
303
+ ready: boolean;
304
+ installed: boolean;
305
+ configured: boolean;
306
+ homepage: string | null;
307
+ description: string | null;
308
+ };
309
+
310
+ type PreferencesDialogMode = "onboarding" | "settings";
311
+ type SettingsTabId =
312
+ | "general"
313
+ | "remote_access"
314
+ | "repositories"
315
+ | "organization"
316
+ | "projects"
317
+ | "agents"
318
+ | "mcp"
319
+ | "preferences";
320
+
321
+ type SettingsTab = {
322
+ id: SettingsTabId;
323
+ label: string;
324
+ icon: LucideIcon;
325
+ implemented: boolean;
326
+ };
327
+
328
+ const SETTINGS_TABS: SettingsTab[] = [
329
+ { id: "general", label: "General", icon: Settings2, implemented: true },
330
+ { id: "remote_access", label: "Remote Access", icon: SlidersHorizontal, implemented: true },
331
+ { id: "repositories", label: "Repositories", icon: FolderGit2, implemented: true },
332
+ { id: "organization", label: "Organization Settings", icon: Building2, implemented: true },
333
+ { id: "projects", label: "Projects", icon: FolderKanban, implemented: false },
334
+ { id: "agents", label: "Agents", icon: Bot, implemented: true },
335
+ { id: "mcp", label: "MCP Servers", icon: PlugZap, implemented: false },
336
+ { id: "preferences", label: "Preferences", icon: SlidersHorizontal, implemented: false },
337
+ ];
338
+
339
+ const ONBOARDING_TABS: SettingsTab[] = [
340
+ { id: "preferences", label: "Preferences", icon: SlidersHorizontal, implemented: true },
341
+ { id: "repositories", label: "Repository", icon: FolderGit2, implemented: true },
342
+ ];
343
+
344
+ const IDE_OPTIONS = [
345
+ { id: "vscode", label: "VS Code" },
346
+ { id: "vscode-insiders", label: "VS Code Insiders" },
347
+ { id: "cursor", label: "Cursor" },
348
+ { id: "windsurf", label: "Windsurf" },
349
+ { id: "intellij-idea", label: "IntelliJ IDEA" },
350
+ { id: "zed", label: "Zed" },
351
+ { id: "xcode", label: "Xcode" },
352
+ { id: "antigravity", label: "Antigravity" },
353
+ { id: "custom", label: "Custom" },
354
+ ];
355
+
356
+ const MARKDOWN_EDITOR_OPTIONS = [
357
+ { id: "obsidian", label: "Obsidian" },
358
+ { id: "vscode", label: "VS Code" },
359
+ { id: "notion", label: "Notion" },
360
+ { id: "typora", label: "Typora" },
361
+ { id: "logseq", label: "Logseq" },
362
+ { id: "custom", label: "Custom" },
363
+ ];
364
+
365
+ const NOTIFICATION_SOUND_OPTIONS = [
366
+ { id: "abstract-sound-1", label: "Abstract Sound 1" },
367
+ { id: "abstract-sound-2", label: "Abstract Sound 2" },
368
+ { id: "abstract-sound-3", label: "Abstract Sound 3" },
369
+ { id: "abstract-sound-4", label: "Abstract Sound 4" },
370
+ { id: "cow-mooing", label: "Cow Mooing" },
371
+ { id: "phone-vibration", label: "Phone Vibration" },
372
+ { id: "rooster", label: "Rooster" },
373
+ ];
374
+
375
+ function toObject(value: unknown): Record<string, unknown> {
376
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
377
+ return { ...(value as Record<string, unknown>) };
378
+ }
379
+
380
+ function normalizePreferences(value: unknown, fallbackAgent: string): PreferencesPayload {
381
+ const payload = toObject(value);
382
+ const notifications = toObject(payload["notifications"]);
383
+ const soundFileRaw = notifications["soundFile"];
384
+ const codingAgent = typeof payload["codingAgent"] === "string" && payload["codingAgent"].trim().length > 0
385
+ ? payload["codingAgent"].trim()
386
+ : fallbackAgent;
387
+ const ide = typeof payload["ide"] === "string" && payload["ide"].trim().length > 0
388
+ ? payload["ide"].trim()
389
+ : "vscode";
390
+ const remoteSshHost = typeof payload["remoteSshHost"] === "string" && payload["remoteSshHost"].trim().length > 0
391
+ ? payload["remoteSshHost"].trim()
392
+ : "";
393
+ const remoteSshUser = typeof payload["remoteSshUser"] === "string" && payload["remoteSshUser"].trim().length > 0
394
+ ? payload["remoteSshUser"].trim()
395
+ : "";
396
+ const markdownEditor = typeof payload["markdownEditor"] === "string" && payload["markdownEditor"].trim().length > 0
397
+ ? payload["markdownEditor"].trim()
398
+ : "obsidian";
399
+
400
+ return {
401
+ onboardingAcknowledged: payload["onboardingAcknowledged"] === true,
402
+ codingAgent,
403
+ ide,
404
+ remoteSshHost,
405
+ remoteSshUser,
406
+ markdownEditor,
407
+ modelAccess: normalizeModelAccessPreferences(payload["modelAccess"]),
408
+ notifications: {
409
+ soundEnabled: notifications["soundEnabled"] !== false,
410
+ soundFile: soundFileRaw === null
411
+ ? null
412
+ : typeof soundFileRaw === "string" && soundFileRaw.trim().length > 0
413
+ ? soundFileRaw.trim()
414
+ : "abstract-sound-4",
415
+ },
416
+ };
417
+ }
418
+
419
+ function normalizeMultilineList(value: unknown): string {
420
+ if (Array.isArray(value)) {
421
+ return value
422
+ .filter((item): item is string => typeof item === "string")
423
+ .map((item) => item.trim())
424
+ .filter(Boolean)
425
+ .join("\n");
426
+ }
427
+ if (typeof value !== "string") return "";
428
+ return value
429
+ .split(/[\n,]+/g)
430
+ .map((item) => item.trim())
431
+ .filter(Boolean)
432
+ .join("\n");
433
+ }
434
+
435
+ function normalizeAccessSettings(value: unknown, summary?: unknown): AccessSettingsPayload {
436
+ const payload = toObject(value);
437
+ const trustedHeaders = toObject(payload["trustedHeaders"]);
438
+ const roles = toObject(payload["roles"]);
439
+ const current = toObject(summary);
440
+ const defaultRoleRaw = payload["defaultRole"];
441
+ const defaultRole: DashboardRole =
442
+ defaultRoleRaw === "viewer" || defaultRoleRaw === "admin" || defaultRoleRaw === "operator"
443
+ ? defaultRoleRaw
444
+ : "operator";
445
+ const currentRoleRaw = current["role"];
446
+
447
+ return {
448
+ requireAuth: payload["requireAuth"] === true,
449
+ defaultRole,
450
+ trustedHeaders: {
451
+ enabled: trustedHeaders["enabled"] === true,
452
+ provider: trustedHeaders["provider"] === "generic" ? "generic" : "cloudflare-access",
453
+ emailHeader: typeof trustedHeaders["emailHeader"] === "string" && trustedHeaders["emailHeader"].trim().length > 0
454
+ ? trustedHeaders["emailHeader"].trim()
455
+ : "Cf-Access-Authenticated-User-Email",
456
+ jwtHeader: typeof trustedHeaders["jwtHeader"] === "string" && trustedHeaders["jwtHeader"].trim().length > 0
457
+ ? trustedHeaders["jwtHeader"].trim()
458
+ : "Cf-Access-Jwt-Assertion",
459
+ teamDomain: typeof trustedHeaders["teamDomain"] === "string" && trustedHeaders["teamDomain"].trim().length > 0
460
+ ? trustedHeaders["teamDomain"].trim()
461
+ : "",
462
+ audience: typeof trustedHeaders["audience"] === "string" && trustedHeaders["audience"].trim().length > 0
463
+ ? trustedHeaders["audience"].trim()
464
+ : "",
465
+ },
466
+ roles: {
467
+ viewers: normalizeMultilineList(roles["viewers"]),
468
+ operators: normalizeMultilineList(roles["operators"]),
469
+ admins: normalizeMultilineList(roles["admins"]),
470
+ viewerDomains: normalizeMultilineList(roles["viewerDomains"]),
471
+ operatorDomains: normalizeMultilineList(roles["operatorDomains"]),
472
+ adminDomains: normalizeMultilineList(roles["adminDomains"]),
473
+ },
474
+ current: {
475
+ authenticated: current["authenticated"] === true,
476
+ role: currentRoleRaw === "viewer" || currentRoleRaw === "operator" || currentRoleRaw === "admin"
477
+ ? currentRoleRaw
478
+ : null,
479
+ email: typeof current["email"] === "string" && current["email"].trim().length > 0
480
+ ? current["email"].trim()
481
+ : null,
482
+ provider: typeof current["provider"] === "string" && current["provider"].trim().length > 0
483
+ ? current["provider"].trim()
484
+ : null,
485
+ },
486
+ };
487
+ }
488
+
489
+ function emptyModelSelection(): ModelSelectionState {
490
+ return {
491
+ catalogModel: "",
492
+ customModel: "",
493
+ reasoningEffort: "",
494
+ };
495
+ }
496
+
497
+ function getRuntimeModelCatalog(
498
+ agent: string,
499
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
500
+ ): RuntimeAgentModelCatalog | null {
501
+ return runtimeModelCatalogs[normalizeAgentName(agent)] ?? null;
502
+ }
503
+
504
+ function getAllRuntimeCatalogModels(
505
+ runtimeCatalog: RuntimeAgentModelCatalog | null,
506
+ ): AgentModelOption[] {
507
+ if (!runtimeCatalog) return [];
508
+
509
+ const ordered: AgentModelOption[] = [];
510
+ const seen = new Set<string>();
511
+ for (const group of Object.values(runtimeCatalog.modelsByAccess)) {
512
+ if (!Array.isArray(group)) continue;
513
+ for (const model of group) {
514
+ if (!model?.id || seen.has(model.id)) continue;
515
+ seen.add(model.id);
516
+ ordered.push(model);
517
+ }
518
+ }
519
+ return ordered;
520
+ }
521
+
522
+ function getSelectableAgentModels(
523
+ agent: string,
524
+ modelAccess: ModelAccessPreferences,
525
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
526
+ ): AgentModelOption[] {
527
+ const runtimeCatalog = getRuntimeModelCatalog(agent, runtimeModelCatalogs);
528
+ const access = resolveAgentModelAccess(agent, modelAccess);
529
+ const scopedModels = getRuntimeCatalogModelsForAccess(runtimeCatalog, access);
530
+ return scopedModels.length > 0 ? scopedModels : getAllRuntimeCatalogModels(runtimeCatalog);
531
+ }
532
+
533
+ function getSelectableAgentReasoningOptions(
534
+ agent: string,
535
+ modelAccess: ModelAccessPreferences,
536
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
537
+ model: string | null | undefined,
538
+ ): AgentReasoningOption[] {
539
+ const runtimeCatalog = getRuntimeModelCatalog(agent, runtimeModelCatalogs);
540
+ const access = resolveAgentModelAccess(agent, modelAccess);
541
+ return getRuntimeCatalogReasoningOptions(runtimeCatalog, model, access);
542
+ }
543
+
544
+ function getSelectableDefaultAgentModel(
545
+ agent: string,
546
+ modelAccess: ModelAccessPreferences,
547
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
548
+ ): string {
549
+ const runtimeCatalog = getRuntimeModelCatalog(agent, runtimeModelCatalogs);
550
+ const access = resolveAgentModelAccess(agent, modelAccess);
551
+ return getRuntimeCatalogDefaultModelForAccess(runtimeCatalog, access)
552
+ ?? getAllRuntimeCatalogModels(runtimeCatalog)[0]?.id
553
+ ?? "";
554
+ }
555
+
556
+ function getSelectableDefaultReasoningEffort(
557
+ agent: string,
558
+ modelAccess: ModelAccessPreferences,
559
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
560
+ model: string | null | undefined,
561
+ ): string {
562
+ const runtimeCatalog = getRuntimeModelCatalog(agent, runtimeModelCatalogs);
563
+ const access = resolveAgentModelAccess(agent, modelAccess);
564
+ return getRuntimeCatalogDefaultReasoning(runtimeCatalog, model, access) ?? "";
565
+ }
566
+
567
+ function getSelectableModelPlaceholder(
568
+ agent: string,
569
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
570
+ ): string {
571
+ const runtimeCatalog = getRuntimeModelCatalog(agent, runtimeModelCatalogs);
572
+ if (runtimeCatalog?.customModelPlaceholder.trim()) {
573
+ return runtimeCatalog.customModelPlaceholder;
574
+ }
575
+ const label = getAgentModelCatalog(agent)?.label ?? "agent";
576
+ return `Enter exact ${label} model id`;
577
+ }
578
+
579
+ function buildModelSelection(
580
+ agent: string,
581
+ modelAccess: ModelAccessPreferences,
582
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>,
583
+ preferredModel?: string | null,
584
+ preferredReasoningEffort?: string | null,
585
+ ): ModelSelectionState {
586
+ const trimmedPreferred = preferredModel?.trim() ?? "";
587
+ const trimmedPreferredReasoning = preferredReasoningEffort?.trim().toLowerCase() ?? "";
588
+ const availableModels = getSelectableAgentModels(agent, modelAccess, runtimeModelCatalogs);
589
+ const defaultModel = getSelectableDefaultAgentModel(agent, modelAccess, runtimeModelCatalogs);
590
+ const resolveReasoningEffort = (resolvedModel: string | null | undefined): string => {
591
+ const options = getSelectableAgentReasoningOptions(agent, modelAccess, runtimeModelCatalogs, resolvedModel);
592
+ if (trimmedPreferredReasoning.length > 0 && options.some((option) => option.id === trimmedPreferredReasoning)) {
593
+ return trimmedPreferredReasoning;
594
+ }
595
+ return getSelectableDefaultReasoningEffort(agent, modelAccess, runtimeModelCatalogs, resolvedModel);
596
+ };
597
+
598
+ if (trimmedPreferred.length > 0) {
599
+ if (availableModels.some((model) => model.id === trimmedPreferred)) {
600
+ return {
601
+ catalogModel: trimmedPreferred,
602
+ customModel: "",
603
+ reasoningEffort: resolveReasoningEffort(trimmedPreferred),
604
+ };
605
+ }
606
+
607
+ return {
608
+ catalogModel: defaultModel,
609
+ customModel: trimmedPreferred,
610
+ reasoningEffort: resolveReasoningEffort(trimmedPreferred),
611
+ };
612
+ }
613
+
614
+ return {
615
+ catalogModel: defaultModel,
616
+ customModel: "",
617
+ reasoningEffort: resolveReasoningEffort(defaultModel),
618
+ };
619
+ }
620
+
621
+ function resolveModelSelectionValue(selection: ModelSelectionState): string | undefined {
622
+ const custom = selection.customModel.trim();
623
+ if (custom.length > 0) return custom;
624
+ const catalog = selection.catalogModel.trim();
625
+ return catalog.length > 0 ? catalog : undefined;
626
+ }
627
+
628
+ function resolveReasoningSelectionValue(selection: ModelSelectionState): string | undefined {
629
+ const reasoningEffort = selection.reasoningEffort.trim().toLowerCase();
630
+ return reasoningEffort.length > 0 ? reasoningEffort : undefined;
631
+ }
632
+
633
+ function getAgentModelAccessLabel(agent: string, modelAccess: ModelAccessPreferences): string | null {
634
+ const catalog = getAgentModelCatalog(agent);
635
+ const access = resolveAgentModelAccess(agent, modelAccess);
636
+ if (!catalog || !access) return null;
637
+
638
+ return catalog.accessOptions.find((option) => option.id === access)?.label ?? null;
639
+ }
640
+
641
+ const MARKDOWN_EDITOR_ICON_CLASS = "block h-4 w-4 shrink-0";
642
+ const CODE_EDITOR_ICON_CLASS = "block h-4 w-4 shrink-0 object-contain";
643
+
644
+ type CodeEditorIconSpec =
645
+ | { kind: "icon"; icon: IconType; className: string }
646
+ | { kind: "image"; imageSrc: string; className: string };
647
+
648
+ const CODE_EDITOR_ICON_MAP: Record<string, CodeEditorIconSpec> = {
649
+ vscode: { kind: "image", imageSrc: "/icons/ide/vscode-dark.svg", className: CODE_EDITOR_ICON_CLASS },
650
+ "vscode-insiders": { kind: "image", imageSrc: "/icons/ide/vscode-insiders.svg", className: CODE_EDITOR_ICON_CLASS },
651
+ cursor: { kind: "image", imageSrc: "/icons/ide/cursor-dark.svg", className: CODE_EDITOR_ICON_CLASS },
652
+ windsurf: { kind: "image", imageSrc: "/icons/ide/windsurf-dark.svg", className: CODE_EDITOR_ICON_CLASS },
653
+ "intellij-idea": { kind: "image", imageSrc: "/icons/ide/intellij.svg", className: CODE_EDITOR_ICON_CLASS },
654
+ zed: { kind: "image", imageSrc: "/icons/ide/zed-dark.svg", className: CODE_EDITOR_ICON_CLASS },
655
+ xcode: { kind: "image", imageSrc: "/icons/ide/xcode.svg", className: CODE_EDITOR_ICON_CLASS },
656
+ antigravity: { kind: "image", imageSrc: "/icons/ide/antigravity-dark.svg", className: CODE_EDITOR_ICON_CLASS },
657
+ custom: { kind: "icon", icon: Settings2, className: `${CODE_EDITOR_ICON_CLASS} text-[var(--vk-text-muted)]` },
658
+ };
659
+
660
+ function CodeEditorIcon({ editorId, label }: { editorId: string; label: string }) {
661
+ const iconSpec = CODE_EDITOR_ICON_MAP[editorId];
662
+ if (!iconSpec) {
663
+ return <Settings2 className={`${CODE_EDITOR_ICON_CLASS} text-[var(--vk-text-muted)]`} />;
664
+ }
665
+ if (iconSpec.kind === "image") {
666
+ return <img src={iconSpec.imageSrc} alt={`${label} logo`} className={iconSpec.className} />;
667
+ }
668
+ const Icon = iconSpec.icon;
669
+ return <Icon className={iconSpec.className} />;
670
+ }
671
+
672
+ function shellQuote(value: string): string {
673
+ return JSON.stringify(value);
674
+ }
675
+
676
+ function buildRepositoryBootstrapCommand(
677
+ repository: RepositorySettingsPayload,
678
+ preferences: Pick<PreferencesPayload, "ide" | "markdownEditor">,
679
+ ): string {
680
+ const initArgs = [
681
+ "npx conductor-oss@latest setup",
682
+ "--yes",
683
+ `--path ${shellQuote(repository.path)}`,
684
+ `--project-id ${shellQuote(repository.id)}`,
685
+ `--display-name ${shellQuote(repository.displayName)}`,
686
+ `--agent ${shellQuote(repository.agent || "claude-code")}`,
687
+ `--ide ${shellQuote(preferences.ide)}`,
688
+ `--markdown-editor ${shellQuote(preferences.markdownEditor)}`,
689
+ ];
690
+
691
+ if (repository.repo.trim().length > 0) {
692
+ initArgs.push(`--repo ${shellQuote(repository.repo.trim())}`);
693
+ }
694
+ if (repository.defaultBranch.trim().length > 0) {
695
+ initArgs.push(`--default-branch ${shellQuote(repository.defaultBranch.trim())}`);
696
+ }
697
+ if (repository.agentModel.trim().length > 0) {
698
+ initArgs.push(`--model ${shellQuote(repository.agentModel.trim())}`);
699
+ }
700
+ if (repository.agentReasoningEffort.trim().length > 0) {
701
+ initArgs.push(`--reasoning-effort ${shellQuote(repository.agentReasoningEffort.trim())}`);
702
+ }
703
+ if (repository.defaultWorkingDirectory.trim().length > 0) {
704
+ initArgs.push(`--default-working-directory ${shellQuote(repository.defaultWorkingDirectory.trim())}`);
705
+ }
706
+
707
+ return initArgs.join(" ");
708
+ }
709
+
710
+ type MarkdownEditorIconSpec =
711
+ | { kind: "icon"; icon: IconType; className: string }
712
+ | { kind: "image"; imageSrc: string; className: string };
713
+
714
+ const MARKDOWN_EDITOR_ICON_MAP: Record<string, MarkdownEditorIconSpec> = {
715
+ obsidian: { kind: "icon", icon: SiObsidian, className: `${MARKDOWN_EDITOR_ICON_CLASS} text-[#8b5cf6]` },
716
+ vscode: { kind: "icon", icon: VscVscode, className: `${MARKDOWN_EDITOR_ICON_CLASS} text-[#22a3f5]` },
717
+ notion: { kind: "icon", icon: SiNotion, className: `${MARKDOWN_EDITOR_ICON_CLASS} text-white` },
718
+ logseq: { kind: "image", imageSrc: "/icons/editors/logseq.svg", className: `${MARKDOWN_EDITOR_ICON_CLASS} object-contain` },
719
+ typora: {
720
+ kind: "image",
721
+ imageSrc: "/icons/editors/typora-32.png",
722
+ className: `${MARKDOWN_EDITOR_ICON_CLASS} rounded-[3px] bg-white/90 p-[1px] object-contain`,
723
+ },
724
+ custom: { kind: "icon", icon: Settings2, className: `${MARKDOWN_EDITOR_ICON_CLASS} text-[var(--vk-text-muted)]` },
725
+ };
726
+
727
+ function MarkdownEditorIcon({ editorId }: { editorId: string }) {
728
+ const iconSpec = MARKDOWN_EDITOR_ICON_MAP[editorId];
729
+ if (!iconSpec) {
730
+ return <BookText className={`${MARKDOWN_EDITOR_ICON_CLASS} text-[var(--vk-text-muted)]`} />;
731
+ }
732
+ if (iconSpec.kind === "image") {
733
+ return <img src={iconSpec.imageSrc} alt="" className={iconSpec.className} />;
734
+ }
735
+ const Icon = iconSpec.icon;
736
+ return <Icon className={iconSpec.className} />;
737
+ }
738
+
739
+ function AgentModelSelector({
740
+ agent,
741
+ modelAccess,
742
+ runtimeModelCatalogs,
743
+ selection,
744
+ onChange,
745
+ compact = false,
746
+ }: {
747
+ agent: string;
748
+ modelAccess: ModelAccessPreferences;
749
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>;
750
+ selection: ModelSelectionState;
751
+ onChange: (next: ModelSelectionState) => void;
752
+ compact?: boolean;
753
+ }) {
754
+ if (!supportsAgentModelSelection(agent)) return null;
755
+
756
+ const catalog = getAgentModelCatalog(agent);
757
+ const availableModels = getSelectableAgentModels(agent, modelAccess, runtimeModelCatalogs);
758
+ const resolvedModel = resolveModelSelectionValue(selection) ?? selection.catalogModel;
759
+ const availableReasoningOptions = getSelectableAgentReasoningOptions(
760
+ agent,
761
+ modelAccess,
762
+ runtimeModelCatalogs,
763
+ resolvedModel,
764
+ );
765
+ const accessLabel = getAgentModelAccessLabel(agent, modelAccess);
766
+
767
+ if (!catalog) return null;
768
+
769
+ return (
770
+ <div className={compact ? "grid gap-3 md:grid-cols-3" : "space-y-3"}>
771
+ <label className="block">
772
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Model</span>
773
+ <select
774
+ value={selection.catalogModel}
775
+ disabled={availableModels.length === 0}
776
+ onChange={(event) => {
777
+ const nextCatalogModel = event.target.value;
778
+ const nextReasoningOptions = getSelectableAgentReasoningOptions(
779
+ agent,
780
+ modelAccess,
781
+ runtimeModelCatalogs,
782
+ nextCatalogModel,
783
+ );
784
+ onChange({
785
+ ...selection,
786
+ catalogModel: nextCatalogModel,
787
+ reasoningEffort: nextReasoningOptions.some((option) => option.id === selection.reasoningEffort)
788
+ ? selection.reasoningEffort
789
+ : getSelectableDefaultReasoningEffort(agent, modelAccess, runtimeModelCatalogs, nextCatalogModel),
790
+ });
791
+ }}
792
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
793
+ >
794
+ {availableModels.length === 0 && (
795
+ <option value="">No runtime models detected</option>
796
+ )}
797
+ {availableModels.map((model) => (
798
+ <option key={model.id} value={model.id}>
799
+ {model.label}
800
+ </option>
801
+ ))}
802
+ </select>
803
+ <p className="mt-1 text-[11px] text-[var(--vk-text-muted)]">
804
+ {accessLabel
805
+ ? `Filtered for ${accessLabel}.`
806
+ : "Filtered for your current access preference."} Leave custom override blank to use this selection.
807
+ </p>
808
+ </label>
809
+
810
+ {availableReasoningOptions.length > 0 && (
811
+ <label className="block">
812
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Reasoning Effort</span>
813
+ <select
814
+ value={selection.reasoningEffort}
815
+ onChange={(event) => {
816
+ onChange({
817
+ ...selection,
818
+ reasoningEffort: event.target.value,
819
+ });
820
+ }}
821
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
822
+ >
823
+ {availableReasoningOptions.map((option) => (
824
+ <option key={option.id} value={option.id}>
825
+ {option.label}
826
+ </option>
827
+ ))}
828
+ </select>
829
+ <p className="mt-1 text-[11px] text-[var(--vk-text-muted)]">
830
+ Choose how much deliberate reasoning the CLI should use before it acts.
831
+ </p>
832
+ </label>
833
+ )}
834
+
835
+ <label className="block">
836
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Custom Model Override</span>
837
+ <input
838
+ value={selection.customModel}
839
+ onChange={(event) => {
840
+ const nextCustomModel = event.target.value;
841
+ const nextResolvedModel = nextCustomModel.trim() || selection.catalogModel;
842
+ const nextReasoningOptions = getSelectableAgentReasoningOptions(
843
+ agent,
844
+ modelAccess,
845
+ runtimeModelCatalogs,
846
+ nextResolvedModel,
847
+ );
848
+ onChange({
849
+ ...selection,
850
+ customModel: nextCustomModel,
851
+ reasoningEffort: nextReasoningOptions.some((option) => option.id === selection.reasoningEffort)
852
+ ? selection.reasoningEffort
853
+ : getSelectableDefaultReasoningEffort(
854
+ agent,
855
+ modelAccess,
856
+ runtimeModelCatalogs,
857
+ nextResolvedModel,
858
+ ),
859
+ });
860
+ }}
861
+ placeholder={getSelectableModelPlaceholder(agent, runtimeModelCatalogs)}
862
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
863
+ />
864
+ <p className="mt-1 text-[11px] text-[var(--vk-text-muted)]">
865
+ Optional. Use this to force an exact model id from the installed CLI when you want to override the detected list.
866
+ </p>
867
+ </label>
868
+ </div>
869
+ );
870
+ }
871
+
872
+ export default function DashboardClient() {
873
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
874
+ const { sessions, error: sessionsError, refresh: refreshSessions } = useSessions(selectedProjectId);
875
+ const { projects, error: configError, refresh: refreshConfig } = useConfig();
876
+ const { agents } = useAgents();
877
+
878
+ const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
879
+ const [sidebarOpen, setSidebarOpen] = useState(true);
880
+
881
+ const [prompt, setPrompt] = useState("");
882
+ const [selectedAgent, setSelectedAgent] = useState("");
883
+ const [launchModelSelection, setLaunchModelSelection] = useState<ModelSelectionState>(emptyModelSelection());
884
+ const [creating, setCreating] = useState(false);
885
+ const [createError, setCreateError] = useState<string | null>(null);
886
+ const [newWorkspaceOpen, setNewWorkspaceOpen] = useState(false);
887
+ const [creatingWorkspace, setCreatingWorkspace] = useState(false);
888
+ const [newWorkspaceError, setNewWorkspaceError] = useState<string | null>(null);
889
+ const [workspaceView, setWorkspaceView] = useState<"chat" | "board">("chat");
890
+ const [preferences, setPreferences] = useState<PreferencesPayload | null>(null);
891
+ const [preferencesLoading, setPreferencesLoading] = useState(true);
892
+ const [preferencesSaving, setPreferencesSaving] = useState(false);
893
+ const [preferencesError, setPreferencesError] = useState<string | null>(null);
894
+ const [preferencesDialogOpen, setPreferencesDialogOpen] = useState(false);
895
+ const [pendingWorkspaceSetup, setPendingWorkspaceSetup] = useState(false);
896
+
897
+ const dashboardSessions = sessions as unknown as DashboardSession[];
898
+ const workspaceError = createError ?? configError ?? sessionsError ?? preferencesError;
899
+
900
+ useEffect(() => {
901
+ if (typeof window === "undefined") return;
902
+ if (window.innerWidth < 1024) {
903
+ setSidebarOpen(false);
904
+ }
905
+ }, []);
906
+
907
+ useEffect(() => {
908
+ if (projects.length === 0) {
909
+ if (selectedProjectId !== null) setSelectedProjectId(null);
910
+ return;
911
+ }
912
+
913
+ if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) {
914
+ setSelectedProjectId(projects[0]?.id ?? null);
915
+ }
916
+ }, [projects, selectedProjectId]);
917
+
918
+ useEffect(() => {
919
+ if (!selectedSessionId) return;
920
+ if (!dashboardSessions.some((session) => session.id === selectedSessionId)) {
921
+ setSelectedSessionId(null);
922
+ }
923
+ }, [dashboardSessions, selectedSessionId]);
924
+
925
+ const selectedSession = useMemo(
926
+ () => dashboardSessions.find((s) => s.id === selectedSessionId) ?? null,
927
+ [dashboardSessions, selectedSessionId],
928
+ );
929
+ const selectedProject = useMemo(
930
+ () => projects.find((project) => project.id === selectedProjectId) ?? null,
931
+ [projects, selectedProjectId],
932
+ );
933
+ const topBarTitle = useMemo(() => {
934
+ if (selectedSession) {
935
+ return [selectedSession.projectId, selectedSession.branch].filter(Boolean).join(" \u00b7 ");
936
+ }
937
+
938
+ if (selectedProject) {
939
+ return [selectedProject.id, selectedProject.defaultBranch || "main"].filter(Boolean).join(" \u00b7 ");
940
+ }
941
+
942
+ return "All Projects";
943
+ }, [selectedProject, selectedSession]);
944
+
945
+ const agentOptions = useMemo(() => {
946
+ const safeAgents = Array.isArray(agents)
947
+ ? agents as Array<{ name?: string; ready?: boolean; configured?: boolean; installed?: boolean }>
948
+ : [];
949
+ const opts = new Set<string>();
950
+
951
+ for (const agent of safeAgents) {
952
+ if (agent.name) {
953
+ opts.add(agent.name);
954
+ }
955
+ }
956
+ for (const project of projects) {
957
+ if (project.agent) opts.add(project.agent);
958
+ }
959
+ if (preferences?.codingAgent) {
960
+ opts.add(preferences.codingAgent);
961
+ }
962
+ if (selectedAgent) {
963
+ opts.add(selectedAgent);
964
+ }
965
+ if (opts.size === 0) {
966
+ opts.add(preferences?.codingAgent || DEFAULT_AGENT);
967
+ }
968
+ return [...opts];
969
+ }, [agents, preferences?.codingAgent, projects, selectedAgent]);
970
+
971
+ const agentStatesByName = useMemo(() => {
972
+ const states: Record<string, AgentSetupState> = {};
973
+ const safeAgents = Array.isArray(agents)
974
+ ? agents as Array<{
975
+ name?: string;
976
+ ready?: boolean;
977
+ installed?: boolean;
978
+ configured?: boolean;
979
+ homepage?: string | null;
980
+ description?: string | null;
981
+ }>
982
+ : [];
983
+
984
+ for (const agent of safeAgents) {
985
+ if (!agent.name) continue;
986
+ states[normalizeAgentName(agent.name)] = {
987
+ name: agent.name,
988
+ ready: agent.ready === true,
989
+ installed: agent.installed !== false,
990
+ configured: agent.configured !== false,
991
+ homepage: typeof agent.homepage === "string" ? agent.homepage : null,
992
+ description: typeof agent.description === "string" ? agent.description : null,
993
+ };
994
+ }
995
+
996
+ return states;
997
+ }, [agents]);
998
+
999
+ const runtimeModelCatalogs = useMemo(() => {
1000
+ const catalogs: Record<string, RuntimeAgentModelCatalog> = {};
1001
+ const safeAgents = Array.isArray(agents)
1002
+ ? agents as Array<{ name?: string; runtimeModelCatalog?: RuntimeAgentModelCatalog | null }>
1003
+ : [];
1004
+
1005
+ for (const agent of safeAgents) {
1006
+ if (!agent.name || !agent.runtimeModelCatalog) continue;
1007
+ catalogs[normalizeAgentName(agent.name)] = agent.runtimeModelCatalog;
1008
+ }
1009
+
1010
+ return catalogs;
1011
+ }, [agents]);
1012
+
1013
+ const openAgentSetup = useCallback((agentName: string) => {
1014
+ const normalized = normalizeAgentName(agentName);
1015
+ const target = agentStatesByName[normalized]?.homepage || AGENT_SETUP_URLS[normalized];
1016
+ if (!target || typeof window === "undefined") return;
1017
+ window.open(target, "_blank", "noopener,noreferrer");
1018
+ }, [agentStatesByName]);
1019
+
1020
+ useEffect(() => {
1021
+ let cancelled = false;
1022
+ async function loadPreferences() {
1023
+ setPreferencesLoading(true);
1024
+ try {
1025
+ const res = await fetch("/api/preferences");
1026
+ const data = (await res.json().catch(() => null)) as
1027
+ | { preferences?: unknown; error?: string }
1028
+ | null;
1029
+ if (!res.ok) {
1030
+ throw new Error(data?.error ?? `Failed to load preferences: ${res.status}`);
1031
+ }
1032
+ if (cancelled) return;
1033
+ const normalized = normalizePreferences(data?.preferences, DEFAULT_AGENT);
1034
+ setPreferences(normalized);
1035
+ setPreferencesError(null);
1036
+ } catch (err) {
1037
+ if (cancelled) return;
1038
+ setPreferences(normalizePreferences(null, DEFAULT_AGENT));
1039
+ setPreferencesError(err instanceof Error ? err.message : "Failed to load preferences");
1040
+ } finally {
1041
+ if (!cancelled) {
1042
+ setPreferencesLoading(false);
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ void loadPreferences();
1048
+ return () => {
1049
+ cancelled = true;
1050
+ };
1051
+ }, []);
1052
+
1053
+ useEffect(() => {
1054
+ if (!preferences) return;
1055
+ if (!selectedAgent) {
1056
+ setSelectedAgent(preferences.codingAgent);
1057
+ }
1058
+ }, [preferences, selectedAgent]);
1059
+
1060
+ useEffect(() => {
1061
+ if (preferencesLoading) return;
1062
+ if (!preferences) return;
1063
+ if (!preferences.onboardingAcknowledged) {
1064
+ setPreferencesDialogOpen(true);
1065
+ }
1066
+ }, [preferences, preferencesLoading]);
1067
+
1068
+ useEffect(() => {
1069
+ if (selectedAgent) return;
1070
+ const fromProject = projects.find((p) => p.id === selectedProjectId)?.agent;
1071
+ if (fromProject) {
1072
+ setSelectedAgent(fromProject);
1073
+ }
1074
+ }, [projects, selectedAgent, selectedProjectId]);
1075
+
1076
+ useEffect(() => {
1077
+ if (agentOptions.length === 0) return;
1078
+ if (!selectedAgent || !agentOptions.includes(selectedAgent)) {
1079
+ const fallbackAgent = preferences?.codingAgent || DEFAULT_AGENT;
1080
+ setSelectedAgent(
1081
+ agentOptions.includes(fallbackAgent)
1082
+ ? fallbackAgent
1083
+ : agentOptions[0] ?? DEFAULT_AGENT,
1084
+ );
1085
+ }
1086
+ }, [agentOptions, preferences?.codingAgent, selectedAgent]);
1087
+
1088
+ useEffect(() => {
1089
+ const effectiveAgent = selectedAgent || selectedProject?.agent || preferences?.codingAgent || DEFAULT_AGENT;
1090
+ const preferredModel = selectedProject && normalizeAgentName(selectedProject.agent) === normalizeAgentName(effectiveAgent)
1091
+ ? selectedProject.agentModel
1092
+ : null;
1093
+ const preferredReasoningEffort = selectedProject && normalizeAgentName(selectedProject.agent) === normalizeAgentName(effectiveAgent)
1094
+ ? selectedProject.agentReasoningEffort
1095
+ : null;
1096
+
1097
+ setLaunchModelSelection(
1098
+ buildModelSelection(
1099
+ effectiveAgent,
1100
+ preferences?.modelAccess ?? normalizeModelAccessPreferences(null),
1101
+ runtimeModelCatalogs,
1102
+ preferredModel,
1103
+ preferredReasoningEffort,
1104
+ ),
1105
+ );
1106
+ }, [preferences?.modelAccess, preferences?.codingAgent, runtimeModelCatalogs, selectedAgent, selectedProject]);
1107
+
1108
+ async function handleSavePreferences(
1109
+ next: PreferencesPayload,
1110
+ options?: { closeDialog?: boolean },
1111
+ ): Promise<boolean> {
1112
+ setPreferencesSaving(true);
1113
+ setPreferencesError(null);
1114
+ try {
1115
+ const res = await fetch("/api/preferences", {
1116
+ method: "PUT",
1117
+ headers: { "Content-Type": "application/json" },
1118
+ body: JSON.stringify(next),
1119
+ });
1120
+ const data = (await res.json().catch(() => null)) as
1121
+ | { preferences?: unknown; error?: string }
1122
+ | null;
1123
+ if (!res.ok) {
1124
+ throw new Error(data?.error ?? `Failed to save preferences: ${res.status}`);
1125
+ }
1126
+ const normalized = normalizePreferences(data?.preferences, next.codingAgent || DEFAULT_AGENT);
1127
+ setPreferences(normalized);
1128
+ setSelectedAgent(normalized.codingAgent);
1129
+ if (options?.closeDialog !== false) {
1130
+ setPreferencesDialogOpen(false);
1131
+ }
1132
+ return true;
1133
+ } catch (err) {
1134
+ setPreferencesError(err instanceof Error ? err.message : "Failed to save preferences");
1135
+ return false;
1136
+ } finally {
1137
+ setPreferencesSaving(false);
1138
+ }
1139
+ }
1140
+
1141
+ const toggleSidebar = useCallback(() => setSidebarOpen((prev) => !prev), []);
1142
+
1143
+ const closeSidebarOnMobile = useCallback(() => {
1144
+ if (typeof window !== "undefined" && window.innerWidth < 1024) {
1145
+ setSidebarOpen(false);
1146
+ }
1147
+ }, []);
1148
+
1149
+ const syncSidebarForViewport = useCallback(() => {
1150
+ if (typeof window !== "undefined" && window.innerWidth < 1024) {
1151
+ setSidebarOpen(false);
1152
+ return;
1153
+ }
1154
+ setSidebarOpen(true);
1155
+ }, []);
1156
+
1157
+ const openWorkspaceDialog = useCallback(() => {
1158
+ setNewWorkspaceError(null);
1159
+ setNewWorkspaceOpen(true);
1160
+ syncSidebarForViewport();
1161
+ }, [syncSidebarForViewport]);
1162
+
1163
+ useEffect(() => {
1164
+ if (!pendingWorkspaceSetup || preferencesDialogOpen) return;
1165
+ setPendingWorkspaceSetup(false);
1166
+ openWorkspaceDialog();
1167
+ }, [pendingWorkspaceSetup, preferencesDialogOpen]);
1168
+
1169
+ const handleCreateSession = useCallback(async (options?: CreateSessionOptions) => {
1170
+ const trimmedPrompt = prompt.trim();
1171
+ if (!trimmedPrompt) return;
1172
+ const resolvedModel = resolveModelSelectionValue(launchModelSelection);
1173
+ const resolvedReasoningEffort = resolveReasoningSelectionValue(launchModelSelection);
1174
+
1175
+ const projectId = options?.projectId ?? selectedProjectId ?? projects[0]?.id;
1176
+ if (!projectId) {
1177
+ setCreateError("No project is configured in conductor.yaml");
1178
+ return;
1179
+ }
1180
+
1181
+ const effectiveAgent = selectedAgent || DEFAULT_AGENT;
1182
+ const selectedAgentState = agentStatesByName[normalizeAgentName(effectiveAgent)] ?? null;
1183
+ if (selectedAgentState && !selectedAgentState.ready) {
1184
+ setCreateError(
1185
+ selectedAgentState.installed
1186
+ ? `${getAgentLabel(effectiveAgent)} is not ready yet. Finish setup or authentication and try again.`
1187
+ : `${getAgentLabel(effectiveAgent)} is not installed on this machine yet. Open setup and try again.`,
1188
+ );
1189
+ openAgentSetup(effectiveAgent);
1190
+ return;
1191
+ }
1192
+
1193
+ setCreating(true);
1194
+ setCreateError(null);
1195
+
1196
+ try {
1197
+ const res = await fetch("/api/spawn", {
1198
+ method: "POST",
1199
+ headers: { "Content-Type": "application/json" },
1200
+ body: JSON.stringify({
1201
+ projectId,
1202
+ prompt: trimmedPrompt,
1203
+ ...(options?.issueId?.trim() ? { issueId: options.issueId.trim() } : {}),
1204
+ agent: effectiveAgent,
1205
+ ...(options?.branch ? { branch: options.branch } : {}),
1206
+ ...(options?.baseBranch ? { baseBranch: options.baseBranch } : {}),
1207
+ ...(typeof options?.useWorktree === "boolean" ? { useWorktree: options.useWorktree } : {}),
1208
+ ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}),
1209
+ ...(resolvedModel ? { model: resolvedModel } : {}),
1210
+ ...(resolvedReasoningEffort ? { reasoningEffort: resolvedReasoningEffort } : {}),
1211
+ }),
1212
+ });
1213
+
1214
+ const data = (await res.json().catch(() => null)) as
1215
+ | { session?: DashboardSession; error?: string }
1216
+ | null;
1217
+
1218
+ if (!res.ok) {
1219
+ throw new Error(data?.error ?? `Failed to create workspace: ${res.status}`);
1220
+ }
1221
+
1222
+ if (!data?.session?.id) {
1223
+ throw new Error("Session created but response is missing session id");
1224
+ }
1225
+
1226
+ setPrompt("");
1227
+ setWorkspaceView("chat");
1228
+ syncSidebarForViewport();
1229
+ await refreshSessions();
1230
+ setSelectedSessionId(data.session.id);
1231
+ } catch (err) {
1232
+ setCreateError(err instanceof Error ? err.message : "Failed to create workspace");
1233
+ } finally {
1234
+ setCreating(false);
1235
+ }
1236
+ }, [
1237
+ agentStatesByName,
1238
+ launchModelSelection,
1239
+ openAgentSetup,
1240
+ projects,
1241
+ prompt,
1242
+ refreshSessions,
1243
+ selectedAgent,
1244
+ selectedProjectId,
1245
+ syncSidebarForViewport,
1246
+ ]);
1247
+
1248
+ const handleArchiveSession = useCallback(async (sessionId: string) => {
1249
+ let res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/archive`, {
1250
+ method: "POST",
1251
+ });
1252
+ let data = (await res.json().catch(() => null)) as
1253
+ | { ok?: boolean; error?: string }
1254
+ | null;
1255
+
1256
+ if (res.status === 404) {
1257
+ res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
1258
+ method: "POST",
1259
+ headers: { "Content-Type": "application/json" },
1260
+ body: JSON.stringify({ action: "archive" }),
1261
+ });
1262
+ data = (await res.json().catch(() => null)) as
1263
+ | { ok?: boolean; error?: string }
1264
+ | null;
1265
+ }
1266
+
1267
+ if (!res.ok) {
1268
+ throw new Error(data?.error ?? `Failed to archive session: ${res.status}`);
1269
+ }
1270
+
1271
+ if (selectedSessionId === sessionId) {
1272
+ setSelectedSessionId(null);
1273
+ }
1274
+
1275
+ await refreshSessions();
1276
+ }, [refreshSessions, selectedSessionId]);
1277
+
1278
+ const handleCreateWorkspace = useCallback(async (payload: NewWorkspacePayload) => {
1279
+ setCreatingWorkspace(true);
1280
+ setNewWorkspaceError(null);
1281
+
1282
+ try {
1283
+ const res = await fetch("/api/workspaces", {
1284
+ method: "POST",
1285
+ headers: { "Content-Type": "application/json" },
1286
+ body: JSON.stringify(payload),
1287
+ });
1288
+
1289
+ const data = (await res.json().catch(() => null)) as
1290
+ | { project?: { id?: string }; error?: string }
1291
+ | null;
1292
+
1293
+ if (!res.ok) {
1294
+ throw new Error(data?.error ?? `Failed to add workspace: ${res.status}`);
1295
+ }
1296
+
1297
+ const createdProjectId = data?.project?.id;
1298
+ if (!createdProjectId) {
1299
+ throw new Error("Workspace created but response is missing project id");
1300
+ }
1301
+
1302
+ await refreshConfig();
1303
+ setSelectedProjectId(createdProjectId);
1304
+ setSelectedSessionId(null);
1305
+ setPrompt("");
1306
+ syncSidebarForViewport();
1307
+ setNewWorkspaceOpen(false);
1308
+ } catch (err) {
1309
+ setNewWorkspaceError(err instanceof Error ? err.message : "Failed to add workspace");
1310
+ } finally {
1311
+ setCreatingWorkspace(false);
1312
+ }
1313
+ }, [refreshConfig, syncSidebarForViewport]);
1314
+
1315
+ const onboardingRequired = !preferencesLoading && !!preferences && !preferences.onboardingAcknowledged;
1316
+ const resolvedPreferences = preferences ?? normalizePreferences(null, selectedAgent || DEFAULT_AGENT);
1317
+ const resolvedCodingAgent = selectedAgent || resolvedPreferences.codingAgent || DEFAULT_AGENT;
1318
+
1319
+ const handleSelectProject = useCallback((projectId: string | null) => {
1320
+ setSelectedProjectId(projectId);
1321
+ setSelectedSessionId(null);
1322
+ const nextAgent = projectId
1323
+ ? projects.find((project) => project.id === projectId)?.agent
1324
+ : preferences?.codingAgent;
1325
+ setSelectedAgent(nextAgent || DEFAULT_AGENT);
1326
+ closeSidebarOnMobile();
1327
+ }, [closeSidebarOnMobile, preferences?.codingAgent, projects]);
1328
+
1329
+ const handleSelectSession = useCallback((id: string) => {
1330
+ setSelectedSessionId(id);
1331
+ closeSidebarOnMobile();
1332
+ }, [closeSidebarOnMobile]);
1333
+
1334
+ const handleOpenPreferences = useCallback(() => {
1335
+ setPreferencesDialogOpen(true);
1336
+ }, []);
1337
+
1338
+ const handleCloseNewWorkspaceDialog = useCallback(() => {
1339
+ if (creatingWorkspace) return;
1340
+ setNewWorkspaceOpen(false);
1341
+ }, [creatingWorkspace]);
1342
+
1343
+ const handleClosePreferencesDialog = useCallback(() => {
1344
+ if (preferencesSaving || onboardingRequired) return;
1345
+ setPreferencesDialogOpen(false);
1346
+ setPreferencesError(null);
1347
+ }, [onboardingRequired, preferencesSaving]);
1348
+
1349
+ const sidebarContent = useMemo(() => (
1350
+ <WorkspaceSidebarPanel
1351
+ orgLabel="conductor-oss"
1352
+ projects={projects}
1353
+ selectedProjectId={selectedProjectId}
1354
+ onSelectProject={handleSelectProject}
1355
+ sessions={dashboardSessions}
1356
+ selectedSessionId={selectedSessionId}
1357
+ onSelectSession={handleSelectSession}
1358
+ onArchiveSession={handleArchiveSession}
1359
+ onCreateWorkspace={openWorkspaceDialog}
1360
+ />
1361
+ ), [
1362
+ dashboardSessions,
1363
+ handleArchiveSession,
1364
+ handleSelectProject,
1365
+ handleSelectSession,
1366
+ openWorkspaceDialog,
1367
+ projects,
1368
+ selectedProjectId,
1369
+ selectedSessionId,
1370
+ ]);
1371
+
1372
+ const workspaceMainPanel = useMemo(() => {
1373
+ if (workspaceView === "board") {
1374
+ return (
1375
+ <WorkspaceKanban
1376
+ projectId={selectedProjectId}
1377
+ defaultAgent={resolvedCodingAgent}
1378
+ agentOptions={agentOptions}
1379
+ />
1380
+ );
1381
+ }
1382
+
1383
+ return (
1384
+ <CreateWorkspacePanel
1385
+ prompt={prompt}
1386
+ setPrompt={setPrompt}
1387
+ selectedAgent={resolvedCodingAgent}
1388
+ setSelectedAgent={setSelectedAgent}
1389
+ agentStates={agentStatesByName}
1390
+ modelSelection={launchModelSelection}
1391
+ setModelSelection={setLaunchModelSelection}
1392
+ modelAccess={resolvedPreferences.modelAccess}
1393
+ runtimeModelCatalogs={runtimeModelCatalogs}
1394
+ agentOptions={agentOptions}
1395
+ projects={projects}
1396
+ selectedProjectId={selectedProjectId}
1397
+ onSelectProject={setSelectedProjectId}
1398
+ projectLabel={selectedProjectId ?? "All projects"}
1399
+ hasProject={projects.length > 0}
1400
+ creating={creating}
1401
+ error={workspaceError}
1402
+ onOpenAddWorkspace={openWorkspaceDialog}
1403
+ onOpenAgentSetup={openAgentSetup}
1404
+ onCreate={handleCreateSession}
1405
+ />
1406
+ );
1407
+ }, [
1408
+ agentOptions,
1409
+ agentStatesByName,
1410
+ creating,
1411
+ handleCreateSession,
1412
+ launchModelSelection,
1413
+ openAgentSetup,
1414
+ openWorkspaceDialog,
1415
+ projects,
1416
+ prompt,
1417
+ resolvedCodingAgent,
1418
+ resolvedPreferences.modelAccess,
1419
+ runtimeModelCatalogs,
1420
+ selectedProjectId,
1421
+ setPrompt,
1422
+ workspaceError,
1423
+ workspaceView,
1424
+ ]);
1425
+
1426
+ const workspaceContent = useMemo(() => {
1427
+ if (selectedSessionId) {
1428
+ return <SessionDetail sessionId={selectedSessionId} />;
1429
+ }
1430
+
1431
+ if (selectedProjectId !== null) {
1432
+ return (
1433
+ <div className="min-h-0 flex-1 overflow-hidden">
1434
+ {workspaceMainPanel}
1435
+ </div>
1436
+ );
1437
+ }
1438
+
1439
+ return (
1440
+ <div className="flex h-full min-h-0 flex-col">
1441
+ <WorkspaceOverview
1442
+ projects={projects}
1443
+ sessions={dashboardSessions}
1444
+ selectedProjectId={selectedProjectId}
1445
+ workspaceView={workspaceView}
1446
+ agentCount={agentOptions.length}
1447
+ onCreateWorkspace={openWorkspaceDialog}
1448
+ onSelectProject={handleSelectProject}
1449
+ onSelectSession={handleSelectSession}
1450
+ onShowView={setWorkspaceView}
1451
+ />
1452
+
1453
+ <div className="min-h-0 flex-1 overflow-hidden">
1454
+ {workspaceMainPanel}
1455
+ </div>
1456
+ </div>
1457
+ );
1458
+ }, [
1459
+ dashboardSessions,
1460
+ selectedSessionId,
1461
+ workspaceMainPanel,
1462
+ handleSelectProject,
1463
+ handleSelectSession,
1464
+ openWorkspaceDialog,
1465
+ projects,
1466
+ selectedProjectId,
1467
+ workspaceView,
1468
+ agentOptions.length,
1469
+ ]);
1470
+
1471
+ return (
1472
+ <>
1473
+ <AppShell
1474
+ sidebarOpen={sidebarOpen}
1475
+ onToggleSidebar={toggleSidebar}
1476
+ sidebar={sidebarContent}
1477
+ >
1478
+ <TopBar
1479
+ title={topBarTitle}
1480
+ onOpenPreferences={handleOpenPreferences}
1481
+ />
1482
+
1483
+ <div className="min-h-0 flex-1 overflow-hidden">
1484
+ {workspaceContent}
1485
+ </div>
1486
+ </AppShell>
1487
+
1488
+ {newWorkspaceOpen ? (
1489
+ <NewWorkspaceDialog
1490
+ open={newWorkspaceOpen}
1491
+ onClose={handleCloseNewWorkspaceDialog}
1492
+ onCreate={handleCreateWorkspace}
1493
+ creating={creatingWorkspace}
1494
+ error={newWorkspaceError}
1495
+ defaultAgent={resolvedCodingAgent}
1496
+ agentOptions={agentOptions}
1497
+ />
1498
+ ) : null}
1499
+
1500
+ {preferencesDialogOpen || onboardingRequired ? (
1501
+ <SettingsDialog
1502
+ open={preferencesDialogOpen}
1503
+ mode={onboardingRequired ? "onboarding" : "settings"}
1504
+ creating={preferencesSaving}
1505
+ error={preferencesError}
1506
+ current={resolvedPreferences}
1507
+ projectCount={projects.length}
1508
+ agentOptions={agentOptions}
1509
+ runtimeModelCatalogs={runtimeModelCatalogs}
1510
+ onRepositoriesChanged={refreshConfig}
1511
+ onOnboardingComplete={({ needsProject }) => {
1512
+ if (needsProject) {
1513
+ setPendingWorkspaceSetup(true);
1514
+ }
1515
+ }}
1516
+ onClose={handleClosePreferencesDialog}
1517
+ onSave={handleSavePreferences}
1518
+ />
1519
+ ) : null}
1520
+ </>
1521
+ );
1522
+ }
1523
+
1524
+ function NewWorkspaceDialog({
1525
+ open,
1526
+ onClose,
1527
+ onCreate,
1528
+ creating,
1529
+ error,
1530
+ defaultAgent,
1531
+ agentOptions,
1532
+ }: {
1533
+ open: boolean;
1534
+ onClose: () => void;
1535
+ onCreate: (payload: NewWorkspacePayload) => Promise<void>;
1536
+ creating: boolean;
1537
+ error: string | null;
1538
+ defaultAgent: string;
1539
+ agentOptions: string[];
1540
+ }) {
1541
+ const [mode, setMode] = useState<"git" | "local">("git");
1542
+ const [projectId, setProjectId] = useState("");
1543
+ const [gitUrl, setGitUrl] = useState("");
1544
+ const [path, setPath] = useState("");
1545
+ const [defaultBranch, setDefaultBranch] = useState("main");
1546
+ const [agent, setAgent] = useState(defaultAgent);
1547
+ const [useWorktree, setUseWorktree] = useState(true);
1548
+ const [initializeGit, setInitializeGit] = useState(true);
1549
+ const [githubRepos, setGithubRepos] = useState<GitHubRepo[]>([]);
1550
+ const [githubReposLoading, setGithubReposLoading] = useState(false);
1551
+ const [githubReposError, setGithubReposError] = useState<string | null>(null);
1552
+ const [githubRepoSearch, setGithubRepoSearch] = useState("");
1553
+ const [selectedGithubRepo, setSelectedGithubRepo] = useState("");
1554
+ const [folderPickerOpen, setFolderPickerOpen] = useState(false);
1555
+ const [folderPickerTarget, setFolderPickerTarget] = useState<"clone" | "local">("local");
1556
+ const [branchOptions, setBranchOptions] = useState<string[]>([]);
1557
+ const [branchesLoading, setBranchesLoading] = useState(false);
1558
+ const [branchesError, setBranchesError] = useState<string | null>(null);
1559
+
1560
+ useEffect(() => {
1561
+ if (!open) return;
1562
+ setMode("git");
1563
+ setProjectId("");
1564
+ setGitUrl("");
1565
+ setPath("");
1566
+ setDefaultBranch("main");
1567
+ setInitializeGit(true);
1568
+ setUseWorktree(true);
1569
+ setAgent(defaultAgent);
1570
+ setGithubRepos([]);
1571
+ setGithubReposError(null);
1572
+ setGithubRepoSearch("");
1573
+ setSelectedGithubRepo("");
1574
+ setBranchOptions([]);
1575
+ setBranchesError(null);
1576
+ setBranchesLoading(false);
1577
+ setFolderPickerOpen(false);
1578
+ setFolderPickerTarget("local");
1579
+ }, [defaultAgent, open]);
1580
+
1581
+ useEffect(() => {
1582
+ if (!open) return;
1583
+ if (mode !== "local") return;
1584
+ if (path.trim().length > 0) return;
1585
+ setFolderPickerTarget("local");
1586
+ setFolderPickerOpen(true);
1587
+ }, [mode, open, path]);
1588
+
1589
+ const filteredGitHubRepos = useMemo(() => {
1590
+ if (githubRepoSearch.trim().length === 0) return githubRepos;
1591
+ const query = githubRepoSearch.trim().toLowerCase();
1592
+ return githubRepos.filter((repo) => {
1593
+ return repo.fullName.toLowerCase().includes(query)
1594
+ || repo.name.toLowerCase().includes(query)
1595
+ || repo.defaultBranch.toLowerCase().includes(query);
1596
+ });
1597
+ }, [githubRepoSearch, githubRepos]);
1598
+
1599
+ const orderedAgentOptions = useMemo(() => {
1600
+ const opts = [...new Set(agentOptions)];
1601
+ if (opts.length === 0) {
1602
+ opts.push(defaultAgent || DEFAULT_AGENT);
1603
+ }
1604
+
1605
+ const rankMap = new Map(EXECUTOR_ORDER.map((name, index) => [name, index]));
1606
+ return opts.sort((left, right) => {
1607
+ const leftRank = rankMap.get(normalizeAgentName(left)) ?? Number.MAX_SAFE_INTEGER;
1608
+ const rightRank = rankMap.get(normalizeAgentName(right)) ?? Number.MAX_SAFE_INTEGER;
1609
+ if (leftRank !== rightRank) return leftRank - rightRank;
1610
+ return getAgentLabel(left).localeCompare(getAgentLabel(right));
1611
+ });
1612
+ }, [agentOptions, defaultAgent]);
1613
+
1614
+ useEffect(() => {
1615
+ if (!orderedAgentOptions.includes(agent)) {
1616
+ setAgent(orderedAgentOptions[0] ?? DEFAULT_AGENT);
1617
+ }
1618
+ }, [agent, orderedAgentOptions]);
1619
+
1620
+ const handleFetchGitHubRepos = async () => {
1621
+ setGithubReposLoading(true);
1622
+ setGithubReposError(null);
1623
+ try {
1624
+ const query = githubRepoSearch.trim();
1625
+ const queryParam = query.length > 0 ? `?q=${encodeURIComponent(query)}` : "";
1626
+ const res = await fetch(`/api/github/repos${queryParam}`);
1627
+ const data = (await res.json().catch(() => null)) as
1628
+ | { repos?: GitHubRepo[]; error?: string }
1629
+ | null;
1630
+ if (!res.ok) {
1631
+ throw new Error(data?.error ?? `Failed to load GitHub repositories (${res.status})`);
1632
+ }
1633
+ setGithubRepos(Array.isArray(data?.repos) ? data.repos : []);
1634
+ } catch (err) {
1635
+ setGithubRepos([]);
1636
+ setGithubReposError(
1637
+ err instanceof Error ? err.message : "Failed to load GitHub repositories",
1638
+ );
1639
+ } finally {
1640
+ setGithubReposLoading(false);
1641
+ }
1642
+ };
1643
+
1644
+ const handleDetectBranches = async (
1645
+ sourceOverride?: { gitUrl?: string; path?: string },
1646
+ ) => {
1647
+ const effectiveGitUrl = sourceOverride?.gitUrl ?? (mode === "git" ? gitUrl.trim() : "");
1648
+ const effectivePath = sourceOverride?.path ?? (mode === "local" ? path.trim() : "");
1649
+
1650
+ if (effectiveGitUrl.length === 0 && effectivePath.length === 0) {
1651
+ setBranchesError(
1652
+ mode === "git"
1653
+ ? "Enter a Git URL first."
1654
+ : "Select a local repository path first.",
1655
+ );
1656
+ return;
1657
+ }
1658
+
1659
+ setBranchesLoading(true);
1660
+ setBranchesError(null);
1661
+ try {
1662
+ const params = new URLSearchParams();
1663
+ if (effectiveGitUrl.length > 0) {
1664
+ params.set("gitUrl", effectiveGitUrl);
1665
+ }
1666
+ if (effectivePath.length > 0) {
1667
+ params.set("path", effectivePath);
1668
+ }
1669
+
1670
+ const res = await fetch(`/api/workspaces/branches?${params.toString()}`);
1671
+ const data = (await res.json().catch(() => null)) as
1672
+ | { branches?: string[]; defaultBranch?: string | null; error?: string }
1673
+ | null;
1674
+
1675
+ if (!res.ok) {
1676
+ throw new Error(data?.error ?? `Failed to load branches (${res.status})`);
1677
+ }
1678
+
1679
+ const branches = Array.isArray(data?.branches)
1680
+ ? data.branches.filter((branch) => typeof branch === "string" && branch.trim().length > 0)
1681
+ : [];
1682
+ setBranchOptions(branches);
1683
+
1684
+ const suggestedDefault = typeof data?.defaultBranch === "string" && data.defaultBranch.trim().length > 0
1685
+ ? data.defaultBranch.trim()
1686
+ : branches[0] ?? null;
1687
+
1688
+ if (suggestedDefault && (defaultBranch.trim().length === 0 || !branches.includes(defaultBranch))) {
1689
+ setDefaultBranch(suggestedDefault);
1690
+ }
1691
+ } catch (err) {
1692
+ setBranchOptions([]);
1693
+ setBranchesError(err instanceof Error ? err.message : "Failed to load branches");
1694
+ } finally {
1695
+ setBranchesLoading(false);
1696
+ }
1697
+ };
1698
+
1699
+ const handleSelectGitHubRepo = async (httpsUrl: string) => {
1700
+ setSelectedGithubRepo(httpsUrl);
1701
+ const selected = githubRepos.find((repo) => repo.httpsUrl === httpsUrl);
1702
+ if (!selected) return;
1703
+
1704
+ setGitUrl(selected.httpsUrl);
1705
+ setDefaultBranch(selected.defaultBranch || "main");
1706
+ if (projectId.trim().length === 0) {
1707
+ const suggestedProjectId = selected.name
1708
+ .toLowerCase()
1709
+ .replace(/[^a-z0-9]+/g, "-")
1710
+ .replace(/^-+|-+$/g, "")
1711
+ .slice(0, 64);
1712
+ setProjectId(suggestedProjectId || projectId);
1713
+ }
1714
+
1715
+ await handleDetectBranches({ gitUrl: selected.httpsUrl });
1716
+ };
1717
+
1718
+ const openFolderPicker = (target: "clone" | "local") => {
1719
+ setFolderPickerTarget(target);
1720
+ setFolderPickerOpen(true);
1721
+ };
1722
+
1723
+ if (!open) return null;
1724
+
1725
+ const canSubmit = mode === "git"
1726
+ ? gitUrl.trim().length > 0 && defaultBranch.trim().length > 0
1727
+ : path.trim().length > 0 && defaultBranch.trim().length > 0;
1728
+
1729
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
1730
+ event.preventDefault();
1731
+ if (!canSubmit || creating) return;
1732
+
1733
+ const payload: NewWorkspacePayload =
1734
+ mode === "git"
1735
+ ? {
1736
+ mode,
1737
+ projectId: projectId.trim() || undefined,
1738
+ agent,
1739
+ defaultBranch: defaultBranch.trim(),
1740
+ useWorktree,
1741
+ gitUrl: gitUrl.trim(),
1742
+ path: path.trim() || undefined,
1743
+ }
1744
+ : {
1745
+ mode,
1746
+ projectId: projectId.trim() || undefined,
1747
+ agent,
1748
+ defaultBranch: defaultBranch.trim(),
1749
+ useWorktree,
1750
+ path: path.trim(),
1751
+ initializeGit,
1752
+ };
1753
+
1754
+ await onCreate(payload);
1755
+ }
1756
+
1757
+ return (
1758
+ <>
1759
+ <div
1760
+ className="fixed inset-0 z-[80] flex items-start justify-center overflow-y-auto bg-black/65 px-3 py-3 sm:items-center sm:py-0"
1761
+ onClick={() => {
1762
+ if (creating || folderPickerOpen) return;
1763
+ onClose();
1764
+ }}
1765
+ role="presentation"
1766
+ >
1767
+ <form
1768
+ onSubmit={handleSubmit}
1769
+ onClick={(event) => event.stopPropagation()}
1770
+ className="flex max-h-[calc(100dvh-1.5rem)] w-full max-w-[760px] flex-col overflow-hidden rounded-[6px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
1771
+ >
1772
+ <header className="flex items-center border-b border-[var(--vk-border)] px-4 py-3">
1773
+ <div>
1774
+ <h2 className="text-[18px] leading-[22px] text-[var(--vk-text-strong)]">Add Workspace</h2>
1775
+ <p className="pt-1 text-[12px] text-[var(--vk-text-muted)]">
1776
+ Select a repository with a folder picker, then choose the target branch.
1777
+ </p>
1778
+ </div>
1779
+ <button
1780
+ type="button"
1781
+ onClick={onClose}
1782
+ disabled={creating}
1783
+ aria-label="Close dialog"
1784
+ className="ml-auto inline-flex h-8 w-8 items-center justify-center rounded-[4px] text-[var(--vk-text-muted)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
1785
+ >
1786
+ <X className="h-4 w-4" />
1787
+ </button>
1788
+ </header>
1789
+
1790
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
1791
+ <div className="inline-flex rounded-[4px] border border-[var(--vk-border)] p-1">
1792
+ <button
1793
+ type="button"
1794
+ onClick={() => setMode("git")}
1795
+ className={`rounded-[3px] px-3 py-1.5 text-[13px] ${
1796
+ mode === "git"
1797
+ ? "bg-[var(--vk-bg-active)] text-[var(--vk-text-strong)]"
1798
+ : "text-[var(--vk-text-muted)] hover:bg-[var(--vk-bg-hover)]"
1799
+ }`}
1800
+ >
1801
+ Git Repository
1802
+ </button>
1803
+ <button
1804
+ type="button"
1805
+ onClick={() => setMode("local")}
1806
+ className={`rounded-[3px] px-3 py-1.5 text-[13px] ${
1807
+ mode === "local"
1808
+ ? "bg-[var(--vk-bg-active)] text-[var(--vk-text-strong)]"
1809
+ : "text-[var(--vk-text-muted)] hover:bg-[var(--vk-bg-hover)]"
1810
+ }`}
1811
+ >
1812
+ Local Folder
1813
+ </button>
1814
+ </div>
1815
+
1816
+ <label className="block">
1817
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Project ID (optional)</span>
1818
+ <input
1819
+ value={projectId}
1820
+ onChange={(event) => setProjectId(event.target.value)}
1821
+ placeholder="auto-derived from repo/folder"
1822
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1823
+ />
1824
+ </label>
1825
+
1826
+ {mode === "git" ? (
1827
+ <>
1828
+ <div className="rounded-[4px] border border-[var(--vk-border)] p-3">
1829
+ <div className="flex items-center gap-2">
1830
+ <Github className="h-4 w-4 text-[var(--vk-text-muted)]" />
1831
+ <span className="text-[12px] font-medium text-[var(--vk-text-normal)]">GitHub Integration</span>
1832
+ </div>
1833
+ <div className="mt-2 flex flex-wrap items-center gap-2">
1834
+ <button
1835
+ type="button"
1836
+ onClick={handleFetchGitHubRepos}
1837
+ disabled={githubReposLoading}
1838
+ className="inline-flex h-8 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
1839
+ >
1840
+ {githubReposLoading ? (
1841
+ <>
1842
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
1843
+ Loading repos...
1844
+ </>
1845
+ ) : "Load My GitHub Repositories"}
1846
+ </button>
1847
+ <div className="relative min-w-[220px] flex-1">
1848
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--vk-text-muted)]" />
1849
+ <input
1850
+ value={githubRepoSearch}
1851
+ onChange={(event) => setGithubRepoSearch(event.target.value)}
1852
+ placeholder="Filter repos..."
1853
+ className="h-8 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent pl-7 pr-2 text-[12px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1854
+ />
1855
+ </div>
1856
+ </div>
1857
+ {filteredGitHubRepos.length > 0 && (
1858
+ <label className="mt-2 block">
1859
+ <span className="mb-1 block text-[11px] text-[var(--vk-text-muted)]">Choose repository</span>
1860
+ <select
1861
+ value={selectedGithubRepo}
1862
+ onChange={(event) => {
1863
+ void handleSelectGitHubRepo(event.target.value);
1864
+ }}
1865
+ className="h-8 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[12px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1866
+ >
1867
+ <option value="">Select a GitHub repo...</option>
1868
+ {filteredGitHubRepos.map((repo) => (
1869
+ <option key={repo.httpsUrl} value={repo.httpsUrl}>
1870
+ {repo.fullName} ({repo.defaultBranch})
1871
+ </option>
1872
+ ))}
1873
+ </select>
1874
+ </label>
1875
+ )}
1876
+ {githubReposError && (
1877
+ <p className="mt-2 text-[11px] text-[var(--vk-red)]">{githubReposError}</p>
1878
+ )}
1879
+ </div>
1880
+
1881
+ <label className="block">
1882
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Git URL</span>
1883
+ <input
1884
+ value={gitUrl}
1885
+ onChange={(event) => setGitUrl(event.target.value)}
1886
+ placeholder="https://github.com/org/repo.git"
1887
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1888
+ />
1889
+ </label>
1890
+
1891
+ <label className="block">
1892
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">
1893
+ Local Path (optional, clone target)
1894
+ </span>
1895
+ <div className="flex items-center gap-2">
1896
+ <input
1897
+ value={path}
1898
+ readOnly
1899
+ onClick={() => openFolderPicker("clone")}
1900
+ placeholder="Use Browse to choose a clone target folder"
1901
+ className="h-9 w-full cursor-pointer rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1902
+ />
1903
+ <button
1904
+ type="button"
1905
+ onClick={() => openFolderPicker("clone")}
1906
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
1907
+ title="Browse folders"
1908
+ >
1909
+ <FolderOpen className="h-4 w-4" />
1910
+ </button>
1911
+ </div>
1912
+ </label>
1913
+ </>
1914
+ ) : (
1915
+ <>
1916
+ <label className="block">
1917
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Local Path</span>
1918
+ <div className="flex items-center gap-2">
1919
+ <input
1920
+ value={path}
1921
+ readOnly
1922
+ onClick={() => openFolderPicker("local")}
1923
+ placeholder="Use Browse to select a repository folder"
1924
+ className="h-9 w-full cursor-pointer rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1925
+ />
1926
+ <button
1927
+ type="button"
1928
+ onClick={() => openFolderPicker("local")}
1929
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
1930
+ title="Browse folders"
1931
+ >
1932
+ <FolderOpen className="h-4 w-4" />
1933
+ </button>
1934
+ </div>
1935
+ </label>
1936
+ <label className="flex items-center gap-2 text-[13px] text-[var(--vk-text-normal)]">
1937
+ <input
1938
+ type="checkbox"
1939
+ checked={initializeGit}
1940
+ onChange={(event) => setInitializeGit(event.target.checked)}
1941
+ className="h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
1942
+ />
1943
+ <span>Initialize git if this folder is non-git</span>
1944
+ </label>
1945
+ </>
1946
+ )}
1947
+
1948
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
1949
+ <label className="block">
1950
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Default Branch</span>
1951
+ <div className="flex items-center gap-2">
1952
+ <input
1953
+ value={defaultBranch}
1954
+ onChange={(event) => setDefaultBranch(event.target.value)}
1955
+ placeholder="main"
1956
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1957
+ />
1958
+ <button
1959
+ type="button"
1960
+ onClick={() => {
1961
+ void handleDetectBranches();
1962
+ }}
1963
+ disabled={branchesLoading}
1964
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
1965
+ title="Detect branches"
1966
+ >
1967
+ {branchesLoading ? (
1968
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1969
+ ) : (
1970
+ <RefreshCcw className="h-3.5 w-3.5" />
1971
+ )}
1972
+ </button>
1973
+ </div>
1974
+ {branchOptions.length > 0 && (
1975
+ <select
1976
+ value={defaultBranch}
1977
+ onChange={(event) => setDefaultBranch(event.target.value)}
1978
+ className="mt-2 h-8 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[12px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1979
+ >
1980
+ {branchOptions.map((branch) => (
1981
+ <option key={branch} value={branch}>
1982
+ {branch}
1983
+ </option>
1984
+ ))}
1985
+ </select>
1986
+ )}
1987
+ {branchesError && (
1988
+ <p className="mt-1 text-[11px] text-[var(--vk-red)]">{branchesError}</p>
1989
+ )}
1990
+ </label>
1991
+
1992
+ <label className="block">
1993
+ <span className="mb-1.5 block text-[12px] text-[var(--vk-text-muted)]">Agent</span>
1994
+ <select
1995
+ value={agent}
1996
+ onChange={(event) => setAgent(event.target.value)}
1997
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
1998
+ >
1999
+ {orderedAgentOptions.map((item) => (
2000
+ <option key={item} value={item} className="bg-[var(--vk-bg-panel)] text-[var(--vk-text-normal)]">
2001
+ {getAgentLabel(item)}
2002
+ </option>
2003
+ ))}
2004
+ </select>
2005
+ </label>
2006
+ </div>
2007
+
2008
+ <label className="flex items-start gap-2 rounded-[4px] border border-[var(--vk-border)] px-2 py-2 text-[13px] text-[var(--vk-text-normal)]">
2009
+ <input
2010
+ type="checkbox"
2011
+ checked={useWorktree}
2012
+ onChange={(event) => setUseWorktree(event.target.checked)}
2013
+ className="mt-0.5 h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
2014
+ />
2015
+ <span>
2016
+ Use worktree isolation
2017
+ <span className="block text-[11px] text-[var(--vk-text-muted)]">
2018
+ If unchecked, sessions run directly on the selected branch in the local repo.
2019
+ </span>
2020
+ </span>
2021
+ </label>
2022
+
2023
+ {error && <p className="text-[12px] text-[var(--vk-red)]">{error}</p>}
2024
+ </div>
2025
+
2026
+ <footer className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--vk-border)] px-4 py-3">
2027
+ <button
2028
+ type="button"
2029
+ onClick={onClose}
2030
+ disabled={creating}
2031
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-3 text-[13px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
2032
+ >
2033
+ Cancel
2034
+ </button>
2035
+ <button
2036
+ type="submit"
2037
+ disabled={!canSubmit || creating}
2038
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
2039
+ >
2040
+ {creating ? (
2041
+ <>
2042
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
2043
+ Adding...
2044
+ </>
2045
+ ) : "Add Workspace"}
2046
+ </button>
2047
+ </footer>
2048
+ </form>
2049
+ </div>
2050
+
2051
+ <FolderPickerDialog
2052
+ open={folderPickerOpen}
2053
+ initialPath={path}
2054
+ title={folderPickerTarget === "local" ? "Select Local Repository" : "Select Clone Target Folder"}
2055
+ description={folderPickerTarget === "local"
2056
+ ? "Choose the local repository folder."
2057
+ : "Choose where the git repository should be cloned."}
2058
+ onClose={() => setFolderPickerOpen(false)}
2059
+ onSelect={(selectedPath) => {
2060
+ setFolderPickerOpen(false);
2061
+ if (!selectedPath) return;
2062
+ setPath(selectedPath);
2063
+ if (mode === "local" || folderPickerTarget === "local") {
2064
+ void handleDetectBranches({ path: selectedPath });
2065
+ }
2066
+ }}
2067
+ />
2068
+ </>
2069
+ );
2070
+ }
2071
+
2072
+ function FolderPickerDialog({
2073
+ open,
2074
+ initialPath,
2075
+ title,
2076
+ description,
2077
+ onClose,
2078
+ onSelect,
2079
+ }: {
2080
+ open: boolean;
2081
+ initialPath?: string;
2082
+ title: string;
2083
+ description: string;
2084
+ onClose: () => void;
2085
+ onSelect: (path: string | null) => void;
2086
+ }) {
2087
+ const [currentPath, setCurrentPath] = useState("");
2088
+ const [manualPath, setManualPath] = useState(initialPath ?? "");
2089
+ const [entries, setEntries] = useState<DirectoryEntry[]>([]);
2090
+ const [search, setSearch] = useState("");
2091
+ const [loading, setLoading] = useState(false);
2092
+ const [error, setError] = useState<string | null>(null);
2093
+
2094
+ useEffect(() => {
2095
+ if (!open) return;
2096
+ setSearch("");
2097
+ setManualPath(initialPath ?? "");
2098
+ const targetPath = initialPath && initialPath.trim().length > 0 ? initialPath.trim() : undefined;
2099
+ const query = targetPath ? `?path=${encodeURIComponent(targetPath)}` : "";
2100
+ setLoading(true);
2101
+ setError(null);
2102
+ fetch(`/api/filesystem/directory${query}`)
2103
+ .then(async (res) => {
2104
+ const data = (await res.json().catch(() => null)) as
2105
+ | { currentPath?: string; entries?: DirectoryEntry[]; error?: string }
2106
+ | null;
2107
+ if (!res.ok) {
2108
+ throw new Error(data?.error ?? `Failed to load directory (${res.status})`);
2109
+ }
2110
+ setCurrentPath(typeof data?.currentPath === "string" ? data.currentPath : "");
2111
+ setEntries(Array.isArray(data?.entries) ? data.entries : []);
2112
+ })
2113
+ .catch((err) => {
2114
+ setError(err instanceof Error ? err.message : "Failed to load directory");
2115
+ setEntries([]);
2116
+ })
2117
+ .finally(() => {
2118
+ setLoading(false);
2119
+ });
2120
+ }, [initialPath, open]);
2121
+
2122
+ const filteredEntries = useMemo(() => {
2123
+ if (search.trim().length === 0) return entries;
2124
+ const query = search.trim().toLowerCase();
2125
+ return entries.filter((entry) => entry.name.toLowerCase().includes(query));
2126
+ }, [entries, search]);
2127
+
2128
+ const loadDirectory = async (path?: string) => {
2129
+ setLoading(true);
2130
+ setError(null);
2131
+ try {
2132
+ const query = path && path.trim().length > 0
2133
+ ? `?path=${encodeURIComponent(path.trim())}`
2134
+ : "";
2135
+ const res = await fetch(`/api/filesystem/directory${query}`);
2136
+ const data = (await res.json().catch(() => null)) as
2137
+ | { currentPath?: string; entries?: DirectoryEntry[]; error?: string }
2138
+ | null;
2139
+ if (!res.ok) {
2140
+ throw new Error(data?.error ?? `Failed to load directory (${res.status})`);
2141
+ }
2142
+ const nextPath = typeof data?.currentPath === "string" ? data.currentPath : "";
2143
+ setCurrentPath(nextPath);
2144
+ setEntries(Array.isArray(data?.entries) ? data.entries : []);
2145
+ setManualPath(nextPath);
2146
+ } catch (err) {
2147
+ setError(err instanceof Error ? err.message : "Failed to load directory");
2148
+ setEntries([]);
2149
+ } finally {
2150
+ setLoading(false);
2151
+ }
2152
+ };
2153
+
2154
+ const handleGoParent = () => {
2155
+ if (!currentPath) return;
2156
+ const normalized = currentPath.replace(/\\/g, "/");
2157
+ if (normalized === "/") return;
2158
+ const parts = normalized.split("/").filter(Boolean);
2159
+ const parent = parts.length > 1 ? `/${parts.slice(0, -1).join("/")}` : "/";
2160
+ void loadDirectory(parent);
2161
+ };
2162
+
2163
+ if (!open) return null;
2164
+
2165
+ return (
2166
+ <div
2167
+ className="fixed inset-0 z-[95] flex items-start justify-center overflow-y-auto bg-black/70 px-3 py-3 sm:items-center sm:py-0"
2168
+ onClick={() => {
2169
+ onClose();
2170
+ onSelect(null);
2171
+ }}
2172
+ role="presentation"
2173
+ >
2174
+ <div
2175
+ className="flex max-h-[calc(100dvh-1.5rem)] w-full max-w-[760px] flex-col overflow-hidden rounded-[6px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
2176
+ onClick={(event) => event.stopPropagation()}
2177
+ >
2178
+ <header className="border-b border-[var(--vk-border)] px-4 py-3">
2179
+ <h3 className="text-[16px] text-[var(--vk-text-strong)]">{title}</h3>
2180
+ <p className="pt-1 text-[12px] text-[var(--vk-text-muted)]">{description}</p>
2181
+ </header>
2182
+
2183
+ <div className="flex min-h-0 flex-1 flex-col gap-3 px-4 py-3">
2184
+ <div className="flex flex-wrap items-center gap-2">
2185
+ <input
2186
+ value={manualPath}
2187
+ onChange={(event) => setManualPath(event.target.value)}
2188
+ placeholder="/path/to/repository"
2189
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
2190
+ />
2191
+ <button
2192
+ type="button"
2193
+ onClick={() => {
2194
+ void loadDirectory(manualPath);
2195
+ }}
2196
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2197
+ >
2198
+ Open
2199
+ </button>
2200
+ </div>
2201
+
2202
+ <div className="flex flex-wrap items-center gap-2">
2203
+ <button
2204
+ type="button"
2205
+ onClick={() => {
2206
+ void loadDirectory();
2207
+ }}
2208
+ className="inline-flex h-8 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2209
+ >
2210
+ Home
2211
+ </button>
2212
+ <button
2213
+ type="button"
2214
+ onClick={handleGoParent}
2215
+ className="inline-flex h-8 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2216
+ >
2217
+ Up
2218
+ </button>
2219
+ <div className="truncate text-[12px] text-[var(--vk-text-muted)]">{currentPath || "Home"}</div>
2220
+ </div>
2221
+
2222
+ <div className="relative">
2223
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--vk-text-muted)]" />
2224
+ <input
2225
+ value={search}
2226
+ onChange={(event) => setSearch(event.target.value)}
2227
+ placeholder="Filter folders"
2228
+ className="h-8 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent pl-7 pr-2 text-[12px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
2229
+ />
2230
+ </div>
2231
+
2232
+ <div className="min-h-0 flex-1 overflow-auto rounded-[4px] border border-[var(--vk-border)]">
2233
+ {loading ? (
2234
+ <div className="px-3 py-3 text-[12px] text-[var(--vk-text-muted)]">Loading...</div>
2235
+ ) : error ? (
2236
+ <div className="px-3 py-3 text-[12px] text-[var(--vk-red)]">{error}</div>
2237
+ ) : filteredEntries.length === 0 ? (
2238
+ <div className="px-3 py-3 text-[12px] text-[var(--vk-text-muted)]">No folders found.</div>
2239
+ ) : (
2240
+ <div className="p-1">
2241
+ {filteredEntries.map((entry) => (
2242
+ <button
2243
+ key={entry.path}
2244
+ type="button"
2245
+ onClick={() => {
2246
+ if (!entry.isDirectory) return;
2247
+ void loadDirectory(entry.path);
2248
+ }}
2249
+ className={`mb-1 flex w-full items-center gap-2 rounded-[4px] px-2 py-2 text-left text-[12px] ${
2250
+ entry.isDirectory
2251
+ ? "text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2252
+ : "cursor-default text-[var(--vk-text-muted)]"
2253
+ }`}
2254
+ >
2255
+ <FolderOpen className="h-4 w-4 shrink-0" />
2256
+ <span className="truncate">{entry.name}</span>
2257
+ {entry.isGitRepo && (
2258
+ <span className="ml-auto rounded-[999px] border border-[var(--vk-border)] px-1.5 py-0.5 text-[10px]">
2259
+ git
2260
+ </span>
2261
+ )}
2262
+ </button>
2263
+ ))}
2264
+ </div>
2265
+ )}
2266
+ </div>
2267
+ </div>
2268
+
2269
+ <footer className="flex items-center justify-end gap-2 border-t border-[var(--vk-border)] px-4 py-3">
2270
+ <button
2271
+ type="button"
2272
+ onClick={() => {
2273
+ onClose();
2274
+ onSelect(null);
2275
+ }}
2276
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-3 text-[13px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2277
+ >
2278
+ Cancel
2279
+ </button>
2280
+ <button
2281
+ type="button"
2282
+ onClick={() => {
2283
+ const selectedPath = manualPath.trim().length > 0 ? manualPath.trim() : currentPath;
2284
+ onClose();
2285
+ onSelect(selectedPath || null);
2286
+ }}
2287
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)]"
2288
+ >
2289
+ Use this folder
2290
+ </button>
2291
+ </footer>
2292
+ </div>
2293
+ </div>
2294
+ );
2295
+ }
2296
+
2297
+ const CreateWorkspacePanel = memo(function CreateWorkspacePanel({
2298
+ prompt,
2299
+ setPrompt,
2300
+ selectedAgent,
2301
+ setSelectedAgent,
2302
+ agentStates,
2303
+ modelSelection,
2304
+ setModelSelection,
2305
+ modelAccess,
2306
+ runtimeModelCatalogs,
2307
+ agentOptions,
2308
+ projects,
2309
+ selectedProjectId,
2310
+ onSelectProject,
2311
+ projectLabel,
2312
+ hasProject,
2313
+ creating,
2314
+ error,
2315
+ onOpenAddWorkspace,
2316
+ onOpenAgentSetup,
2317
+ onCreate,
2318
+ }: {
2319
+ prompt: string;
2320
+ setPrompt: (value: string) => void;
2321
+ selectedAgent: string;
2322
+ setSelectedAgent: (value: string) => void;
2323
+ agentStates: Record<string, AgentSetupState>;
2324
+ modelSelection: ModelSelectionState;
2325
+ setModelSelection: (next: ModelSelectionState) => void;
2326
+ modelAccess: ModelAccessPreferences;
2327
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>;
2328
+ agentOptions: string[];
2329
+ projects: ConfigProject[];
2330
+ selectedProjectId: string | null;
2331
+ onSelectProject: (projectId: string | null) => void;
2332
+ projectLabel: string;
2333
+ hasProject: boolean;
2334
+ creating: boolean;
2335
+ error: string | null;
2336
+ onOpenAddWorkspace: () => void;
2337
+ onOpenAgentSetup: (agent: string) => void;
2338
+ onCreate: (options?: CreateSessionOptions) => void;
2339
+ }) {
2340
+ const orderedAgentOptions = useMemo(() => {
2341
+ const rankMap = new Map(EXECUTOR_ORDER.map((name, index) => [name, index]));
2342
+ return [...agentOptions].sort((left, right) => {
2343
+ const leftRank = rankMap.get(normalizeAgentName(left)) ?? Number.MAX_SAFE_INTEGER;
2344
+ const rightRank = rankMap.get(normalizeAgentName(right)) ?? Number.MAX_SAFE_INTEGER;
2345
+ if (leftRank !== rightRank) return leftRank - rightRank;
2346
+ return getAgentLabel(left).localeCompare(getAgentLabel(right));
2347
+ });
2348
+ }, [agentOptions]);
2349
+
2350
+ const selectedAgentLabel = getAgentLabel(selectedAgent);
2351
+ const selectedAgentState = agentStates[normalizeAgentName(selectedAgent)] ?? null;
2352
+ const projectOptions = useMemo(
2353
+ () => [...projects].sort((left, right) => left.id.localeCompare(right.id)),
2354
+ [projects],
2355
+ );
2356
+ const effectiveProjectId = selectedProjectId ?? projectOptions[0]?.id ?? null;
2357
+ const selectedProject = useMemo(
2358
+ () => projectOptions.find((project) => project.id === effectiveProjectId) ?? null,
2359
+ [effectiveProjectId, projectOptions],
2360
+ );
2361
+ const [branchOptions, setBranchOptions] = useState<string[]>([]);
2362
+ const [branchLoading, setBranchLoading] = useState(false);
2363
+ const [selectedBranch, setSelectedBranch] = useState("");
2364
+ const [issueId, setIssueId] = useState("");
2365
+ const [availableTasks, setAvailableTasks] = useState<LinkedBoardTask[]>([]);
2366
+ const [taskLoading, setTaskLoading] = useState(false);
2367
+ const [useWorktree, setUseWorktree] = useState(true);
2368
+ const [permissionMode, setPermissionMode] = useState<CreatePermissionMode>("default");
2369
+
2370
+ useEffect(() => {
2371
+ if (!selectedProject) {
2372
+ setBranchOptions([]);
2373
+ setSelectedBranch("");
2374
+ return;
2375
+ }
2376
+
2377
+ let cancelled = false;
2378
+ const project = selectedProject;
2379
+ const fallbackBranch = project.defaultBranch.trim() || "main";
2380
+
2381
+ async function loadBranches() {
2382
+ if (!project.path?.trim()) {
2383
+ setBranchOptions([fallbackBranch]);
2384
+ setSelectedBranch(fallbackBranch);
2385
+ return;
2386
+ }
2387
+
2388
+ setBranchLoading(true);
2389
+ try {
2390
+ const params = new URLSearchParams({ path: project.path });
2391
+ const res = await fetch(`/api/workspaces/branches?${params.toString()}`);
2392
+ const data = (await res.json().catch(() => null)) as
2393
+ | { branches?: string[]; defaultBranch?: string | null }
2394
+ | null;
2395
+
2396
+ const branches = Array.isArray(data?.branches)
2397
+ ? data.branches.filter((branch) => typeof branch === "string" && branch.trim().length > 0)
2398
+ : [];
2399
+ const resolvedDefault = typeof data?.defaultBranch === "string" && data.defaultBranch.trim().length > 0
2400
+ ? data.defaultBranch.trim()
2401
+ : fallbackBranch;
2402
+ const nextBranches = branches.length > 0 ? branches : [resolvedDefault];
2403
+
2404
+ if (cancelled) return;
2405
+ setBranchOptions(nextBranches);
2406
+ setSelectedBranch((current) => current.trim().length > 0 && nextBranches.includes(current) ? current : resolvedDefault);
2407
+ } catch {
2408
+ if (cancelled) return;
2409
+ setBranchOptions([fallbackBranch]);
2410
+ setSelectedBranch(fallbackBranch);
2411
+ } finally {
2412
+ if (!cancelled) {
2413
+ setBranchLoading(false);
2414
+ }
2415
+ }
2416
+ }
2417
+
2418
+ void loadBranches();
2419
+ return () => {
2420
+ cancelled = true;
2421
+ };
2422
+ }, [selectedProject]);
2423
+
2424
+ useEffect(() => {
2425
+ if (!effectiveProjectId) {
2426
+ setAvailableTasks([]);
2427
+ setIssueId("");
2428
+ return;
2429
+ }
2430
+
2431
+ let cancelled = false;
2432
+
2433
+ async function loadTasks() {
2434
+ setTaskLoading(true);
2435
+ try {
2436
+ const res = await fetch(`/api/boards?projectId=${encodeURIComponent(effectiveProjectId)}`);
2437
+ const payload = (await res.json().catch(() => null)) as LinkedBoardResponse | { error?: string } | null;
2438
+ if (!res.ok) {
2439
+ throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load tasks: ${res.status}`);
2440
+ }
2441
+
2442
+ const boardPayload = payload as LinkedBoardResponse | null;
2443
+ const columns = Array.isArray(boardPayload?.columns) ? boardPayload.columns : [];
2444
+ const nextTasks = columns.flatMap((column: { tasks?: LinkedBoardTask[] }) =>
2445
+ Array.isArray(column.tasks) ? column.tasks : [],
2446
+ );
2447
+ const seen = new Set<string>();
2448
+ const deduped = nextTasks.filter((task: LinkedBoardTask) => {
2449
+ const key = getLinkedTaskValue(task);
2450
+ if (!key || seen.has(key)) return false;
2451
+ seen.add(key);
2452
+ return true;
2453
+ });
2454
+
2455
+ if (cancelled) return;
2456
+ setAvailableTasks(deduped);
2457
+ setIssueId((current) => deduped.some((task: LinkedBoardTask) => getLinkedTaskValue(task) === current) ? current : "");
2458
+ } catch {
2459
+ if (cancelled) return;
2460
+ setAvailableTasks([]);
2461
+ setIssueId("");
2462
+ } finally {
2463
+ if (!cancelled) {
2464
+ setTaskLoading(false);
2465
+ }
2466
+ }
2467
+ }
2468
+
2469
+ void loadTasks();
2470
+ return () => {
2471
+ cancelled = true;
2472
+ };
2473
+ }, [effectiveProjectId]);
2474
+
2475
+ const availableModels = useMemo(
2476
+ () => getSelectableAgentModels(selectedAgent, modelAccess, runtimeModelCatalogs),
2477
+ [modelAccess, runtimeModelCatalogs, selectedAgent],
2478
+ );
2479
+ const selectedTask = useMemo(
2480
+ () => availableTasks.find((task) => getLinkedTaskValue(task) === issueId) ?? null,
2481
+ [availableTasks, issueId],
2482
+ );
2483
+ const selectedModelValue = resolveModelSelectionValue(modelSelection) ?? "";
2484
+ const modelMenuOptions = useMemo(() => {
2485
+ const seen = new Set<string>();
2486
+ const merged: AgentModelOption[] = [];
2487
+ const currentModel = selectedModelValue.trim();
2488
+
2489
+ for (const option of availableModels) {
2490
+ if (seen.has(option.id)) continue;
2491
+ seen.add(option.id);
2492
+ merged.push(option);
2493
+ }
2494
+
2495
+ if (currentModel && !seen.has(currentModel)) {
2496
+ seen.add(currentModel);
2497
+ merged.unshift({
2498
+ id: currentModel,
2499
+ label: formatCurrentModelLabel(selectedAgent, currentModel),
2500
+ description: "Current selected model.",
2501
+ access: [],
2502
+ });
2503
+ }
2504
+
2505
+ return merged;
2506
+ }, [availableModels, selectedAgent, selectedModelValue]);
2507
+ const selectedModelLabel = useMemo(() => {
2508
+ if (selectedAgentState && !selectedAgentState.ready && !selectedModelValue) return "Setup required";
2509
+ if (!selectedModelValue) return "Default";
2510
+ return modelMenuOptions.find((option) => option.id === selectedModelValue)?.label ?? selectedModelValue;
2511
+ }, [modelMenuOptions, selectedAgentState, selectedModelValue]);
2512
+ const lightMenuClass = "z-50 min-w-[240px] rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] p-2 shadow-[0_18px_50px_rgba(0,0,0,0.35)]";
2513
+ const scrollMenuClass = `${lightMenuClass} max-h-[min(360px,50vh)] overflow-y-auto`;
2514
+ const lightMenuItemClass = "flex min-h-[36px] cursor-default items-center gap-2 rounded-[3px] px-3 py-2 text-[14px] leading-[21px] text-[var(--vk-text-normal)] outline-none hover:bg-[var(--vk-bg-hover)] focus:bg-[var(--vk-bg-hover)]";
2515
+ const permissionOptions: Array<{ id: CreatePermissionMode; label: string; icon: LucideIcon }> = [
2516
+ { id: "default", label: "Default", icon: SlidersHorizontal },
2517
+ { id: "auto", label: "Auto", icon: ChevronsRight },
2518
+ { id: "ask", label: "Ask", icon: Hand },
2519
+ { id: "plan", label: "Plan", icon: List },
2520
+ ];
2521
+ const selectedPermission = permissionOptions.find((option) => option.id === permissionMode) ?? permissionOptions[0];
2522
+ const getProjectDisplayName = (project: ConfigProject): string => {
2523
+ const repo = project.repo?.trim();
2524
+ if (repo) {
2525
+ const parts = repo.split("/").filter(Boolean);
2526
+ const label = parts[parts.length - 1]?.replace(/\.git$/i, "");
2527
+ if (label) return label;
2528
+ }
2529
+ return project.id;
2530
+ };
2531
+ const selectedProjectLabel = selectedProject ? getProjectDisplayName(selectedProject) : null;
2532
+ const currentProjectLabel = selectedProject
2533
+ ? `${selectedProjectLabel} · ${selectedBranch || selectedProject.defaultBranch || "main"}`
2534
+ : hasProject
2535
+ ? projectLabel
2536
+ : "Select project";
2537
+ const selectedTaskLabel = selectedTask?.taskRef?.trim() || "Link task";
2538
+ const selectedTaskSubtitle = selectedTask ? getLinkedTaskTitle(selectedTask.text) : "Choose a task, bug, or issue from this project's board";
2539
+
2540
+ return (
2541
+ <section className="flex h-full min-h-0 items-start justify-center overflow-auto bg-[var(--vk-bg-main)] px-3 py-4 sm:items-center sm:px-6 sm:py-6">
2542
+ <div className="w-full max-w-[768px]">
2543
+ <h1 className="pb-4 text-center text-[30px] font-medium leading-[34px] tracking-[-0.7px] text-[var(--vk-text-strong)] sm:text-[36px] sm:leading-[40px] sm:tracking-[-0.9px]">
2544
+ What would you like to work on?
2545
+ </h1>
2546
+
2547
+ <div className="mx-auto w-full rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] p-px">
2548
+ <div className="flex flex-wrap items-center gap-2 border-b border-[var(--vk-border)] px-2 pb-[9px] pt-2">
2549
+ <AgentTileIcon seed={{ label: selectedAgent }} className="h-[25px] w-[25px] border-none bg-transparent" />
2550
+ <DropdownMenu.Root>
2551
+ <DropdownMenu.Trigger asChild>
2552
+ <button
2553
+ type="button"
2554
+ className="inline-flex h-[31px] max-w-[70vw] items-center rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-[9px] py-[5px] text-[14px] leading-[21px] text-[var(--vk-text-normal)] outline-none hover:bg-[var(--vk-bg-hover)] data-[state=open]:bg-[var(--vk-bg-hover)] sm:max-w-none"
2555
+ aria-label="Select agent"
2556
+ >
2557
+ <span className="truncate pr-1">{selectedAgentLabel}</span>
2558
+ <ChevronDown className="h-3 w-3 text-[var(--vk-text-muted)]" />
2559
+ </button>
2560
+ </DropdownMenu.Trigger>
2561
+
2562
+ <DropdownMenu.Portal>
2563
+ <DropdownMenu.Content
2564
+ align="start"
2565
+ sideOffset={6}
2566
+ className={lightMenuClass}
2567
+ >
2568
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">
2569
+ Agents
2570
+ </p>
2571
+
2572
+ {orderedAgentOptions.map((agent) => {
2573
+ const isSelected = agent === selectedAgent;
2574
+ const agentState = agentStates[normalizeAgentName(agent)] ?? null;
2575
+ return (
2576
+ <DropdownMenu.Item
2577
+ key={agent}
2578
+ onSelect={() => setSelectedAgent(agent)}
2579
+ className={lightMenuItemClass}
2580
+ >
2581
+ <AgentTileIcon seed={{ label: agent }} className="h-6 w-6 border-none bg-transparent" />
2582
+ <div className="min-w-0 flex-1">
2583
+ <div>{getAgentLabel(agent)}</div>
2584
+ {!agentState?.ready ? (
2585
+ <div className="truncate text-[12px] leading-[16px] text-[var(--vk-text-muted)]">
2586
+ {agentState?.installed ? "Setup required" : "Not installed"}
2587
+ </div>
2588
+ ) : null}
2589
+ </div>
2590
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2591
+ {isSelected ? <Check className="h-4 w-4" /> : null}
2592
+ </span>
2593
+ </DropdownMenu.Item>
2594
+ );
2595
+ })}
2596
+ </DropdownMenu.Content>
2597
+ </DropdownMenu.Portal>
2598
+ </DropdownMenu.Root>
2599
+
2600
+ <DropdownMenu.Root>
2601
+ <DropdownMenu.Trigger asChild>
2602
+ <button
2603
+ type="button"
2604
+ disabled={!effectiveProjectId}
2605
+ className="ml-auto flex h-[31px] min-w-[220px] items-center rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-[9px] py-[5px] text-left disabled:cursor-not-allowed disabled:opacity-50 sm:ml-0 sm:w-[286px]"
2606
+ aria-label="Link task"
2607
+ >
2608
+ <span className="pr-2 text-[12px] uppercase tracking-[0.08em] text-[var(--vk-text-muted)]">Task</span>
2609
+ <span className="min-w-0 flex-1 truncate text-[14px] leading-[21px] text-[var(--vk-text-normal)]">
2610
+ {selectedTaskLabel}
2611
+ </span>
2612
+ <ChevronDown className="h-3 w-3 text-[var(--vk-text-muted)]" />
2613
+ </button>
2614
+ </DropdownMenu.Trigger>
2615
+ <DropdownMenu.Portal>
2616
+ <DropdownMenu.Content
2617
+ align="end"
2618
+ side="bottom"
2619
+ sideOffset={6}
2620
+ className={scrollMenuClass}
2621
+ >
2622
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">
2623
+ Link task
2624
+ </p>
2625
+ <p className="px-3 pb-2 text-[12px] leading-[16px] text-[var(--text-faint)]">
2626
+ {selectedTaskSubtitle}
2627
+ </p>
2628
+ <DropdownMenu.Item
2629
+ onSelect={() => setIssueId("")}
2630
+ className={lightMenuItemClass}
2631
+ >
2632
+ <span>No linked task</span>
2633
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2634
+ {!issueId ? <Check className="h-4 w-4" /> : null}
2635
+ </span>
2636
+ </DropdownMenu.Item>
2637
+ {taskLoading ? (
2638
+ <div className="px-3 py-2 text-[12px] leading-[18px] text-[var(--vk-text-muted)]">
2639
+ Loading board tasks...
2640
+ </div>
2641
+ ) : availableTasks.length > 0 ? (
2642
+ availableTasks.map((task) => {
2643
+ const taskValue = getLinkedTaskValue(task);
2644
+ const title = getLinkedTaskTitle(task.text);
2645
+ const secondary = [task.type, task.priority].filter(Boolean).join(" · ");
2646
+ return (
2647
+ <DropdownMenu.Item
2648
+ key={taskValue}
2649
+ onSelect={() => setIssueId(taskValue)}
2650
+ className={`${lightMenuItemClass} min-w-[320px] items-start`}
2651
+ >
2652
+ <div className="min-w-0 flex-1">
2653
+ <div className="truncate">
2654
+ {task.taskRef?.trim() || title}
2655
+ </div>
2656
+ <div className="truncate text-[12px] leading-[16px] text-[var(--text-faint)]">
2657
+ {task.taskRef?.trim() ? title : taskValue}
2658
+ {secondary ? ` · ${secondary}` : ""}
2659
+ </div>
2660
+ </div>
2661
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2662
+ {issueId === taskValue ? <Check className="h-4 w-4" /> : null}
2663
+ </span>
2664
+ </DropdownMenu.Item>
2665
+ );
2666
+ })
2667
+ ) : (
2668
+ <div className="px-3 py-2 text-[12px] leading-[18px] text-[var(--vk-text-muted)]">
2669
+ No existing tasks were found for this project.
2670
+ </div>
2671
+ )}
2672
+ </DropdownMenu.Content>
2673
+ </DropdownMenu.Portal>
2674
+ </DropdownMenu.Root>
2675
+ </div>
2676
+
2677
+ <div className="rounded-[3.5px]">
2678
+ <div className="flex flex-col gap-3 p-2">
2679
+ <div className="relative w-full">
2680
+ <textarea
2681
+ value={prompt}
2682
+ onChange={(e) => setPrompt(e.target.value)}
2683
+ placeholder="Describe the task..."
2684
+ rows={1}
2685
+ className="min-h-[24px] w-full resize-none bg-transparent pr-8 text-[16px] leading-[24px] text-[var(--vk-text-normal)] outline-none placeholder:text-[var(--vk-text-muted)]"
2686
+ />
2687
+ <button
2688
+ type="button"
2689
+ aria-label="Preview"
2690
+ className="absolute right-0 top-0 inline-flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--vk-text-muted)] hover:bg-[var(--vk-bg-hover)]"
2691
+ >
2692
+ <Eye className="h-[14px] w-[14px]" />
2693
+ </button>
2694
+ </div>
2695
+
2696
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
2697
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1 gap-y-2">
2698
+ <DropdownMenu.Root>
2699
+ <DropdownMenu.Trigger asChild>
2700
+ <button
2701
+ type="button"
2702
+ className="inline-flex h-[29px] w-[29px] items-center justify-center rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2703
+ aria-label="Select workspace or project"
2704
+ >
2705
+ <SlidersHorizontal className="h-[15px] w-[15px]" />
2706
+ </button>
2707
+ </DropdownMenu.Trigger>
2708
+ <DropdownMenu.Portal>
2709
+ <DropdownMenu.Content align="start" sideOffset={6} className={lightMenuClass}>
2710
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">Projects</p>
2711
+ {projectOptions.map((project) => {
2712
+ const displayName = getProjectDisplayName(project);
2713
+ const secondaryLabel = project.id !== displayName
2714
+ ? project.id
2715
+ : project.path?.trim() || project.repo?.trim() || null;
2716
+ return (
2717
+ <DropdownMenu.Item
2718
+ key={project.id}
2719
+ onSelect={() => onSelectProject(project.id)}
2720
+ className={`${lightMenuItemClass} min-w-[280px] items-start`}
2721
+ >
2722
+ <div className="min-w-0 flex-1">
2723
+ <div className="truncate">{displayName}</div>
2724
+ {secondaryLabel ? (
2725
+ <div className="truncate text-[12px] leading-[16px] text-[var(--text-faint)]">
2726
+ {secondaryLabel}
2727
+ </div>
2728
+ ) : null}
2729
+ </div>
2730
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2731
+ {project.id === effectiveProjectId ? <Check className="h-4 w-4" /> : null}
2732
+ </span>
2733
+ </DropdownMenu.Item>
2734
+ );
2735
+ })}
2736
+ <DropdownMenu.Separator className="my-1 h-px bg-[var(--vk-border)]" />
2737
+ <DropdownMenu.Item onSelect={onOpenAddWorkspace} className={lightMenuItemClass}>
2738
+ <FolderOpen className="h-4 w-4" />
2739
+ <span>Add Workspace</span>
2740
+ </DropdownMenu.Item>
2741
+ </DropdownMenu.Content>
2742
+ </DropdownMenu.Portal>
2743
+ </DropdownMenu.Root>
2744
+
2745
+ <DropdownMenu.Root>
2746
+ <DropdownMenu.Trigger asChild>
2747
+ <button
2748
+ type="button"
2749
+ className="inline-flex h-[29px] items-center gap-[4px] rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-[9px] py-[5px] text-[14px] leading-[21px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60"
2750
+ >
2751
+ <span>{selectedModelLabel}</span>
2752
+ <ChevronDown className="h-[10px] w-[10px] text-[var(--vk-text-muted)]" />
2753
+ </button>
2754
+ </DropdownMenu.Trigger>
2755
+ <DropdownMenu.Portal>
2756
+ <DropdownMenu.Content align="start" sideOffset={6} className={lightMenuClass}>
2757
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">Model</p>
2758
+ <DropdownMenu.Item
2759
+ onSelect={() => setModelSelection(buildModelSelection(
2760
+ selectedAgent,
2761
+ modelAccess,
2762
+ runtimeModelCatalogs,
2763
+ selectedProject?.agentModel,
2764
+ selectedProject?.agentReasoningEffort,
2765
+ ))}
2766
+ className={lightMenuItemClass}
2767
+ >
2768
+ <span>Default</span>
2769
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2770
+ {!selectedModelValue ? <Check className="h-4 w-4" /> : null}
2771
+ </span>
2772
+ </DropdownMenu.Item>
2773
+ {modelMenuOptions.map((option) => (
2774
+ <DropdownMenu.Item
2775
+ key={option.id}
2776
+ onSelect={() => setModelSelection({
2777
+ catalogModel: option.id,
2778
+ customModel: "",
2779
+ reasoningEffort: getSelectableDefaultReasoningEffort(
2780
+ selectedAgent,
2781
+ modelAccess,
2782
+ runtimeModelCatalogs,
2783
+ option.id,
2784
+ ),
2785
+ })}
2786
+ className={lightMenuItemClass}
2787
+ >
2788
+ <span>{option.label}</span>
2789
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2790
+ {selectedModelValue === option.id ? <Check className="h-4 w-4" /> : null}
2791
+ </span>
2792
+ </DropdownMenu.Item>
2793
+ ))}
2794
+ {modelMenuOptions.length === 0 ? (
2795
+ <div className="px-3 py-2 text-[12px] leading-[18px] text-[var(--vk-text-muted)]">
2796
+ Models will appear here after the selected agent is installed and its runtime catalog is detected.
2797
+ </div>
2798
+ ) : null}
2799
+ {selectedAgentState && !selectedAgentState.ready ? (
2800
+ <>
2801
+ <DropdownMenu.Separator className="my-1 h-px bg-[var(--vk-border)]" />
2802
+ <button
2803
+ type="button"
2804
+ onClick={() => onOpenAgentSetup(selectedAgent)}
2805
+ className="flex w-full items-center rounded-[3px] px-3 py-2 text-left text-[13px] text-[var(--vk-orange)] transition hover:bg-[var(--vk-bg-hover)]"
2806
+ >
2807
+ {selectedAgentState.installed ? "Open setup" : "Open install guide"}
2808
+ </button>
2809
+ </>
2810
+ ) : null}
2811
+ </DropdownMenu.Content>
2812
+ </DropdownMenu.Portal>
2813
+ </DropdownMenu.Root>
2814
+
2815
+ <div className="inline-flex h-[29px] w-[29px] items-center justify-center rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] text-[var(--vk-text-normal)]">
2816
+ <ChevronsRight className="h-[15px] w-[15px]" />
2817
+ </div>
2818
+
2819
+ <DropdownMenu.Root>
2820
+ <DropdownMenu.Trigger asChild>
2821
+ <button
2822
+ type="button"
2823
+ className="inline-flex h-[29px] items-center gap-[4px] rounded-[3px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-[9px] py-[5px] text-[14px] leading-[21px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2824
+ >
2825
+ <span>{selectedPermission.label}</span>
2826
+ <ChevronDown className="h-[10px] w-[10px] text-[var(--vk-text-muted)]" />
2827
+ </button>
2828
+ </DropdownMenu.Trigger>
2829
+ <DropdownMenu.Portal>
2830
+ <DropdownMenu.Content align="start" sideOffset={6} className={lightMenuClass}>
2831
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">Permissions</p>
2832
+ {permissionOptions.map(({ id, label, icon: Icon }) => (
2833
+ <DropdownMenu.Item
2834
+ key={id}
2835
+ onSelect={() => setPermissionMode(id)}
2836
+ className={lightMenuItemClass}
2837
+ >
2838
+ <Icon className="h-4 w-4" />
2839
+ <span>{label}</span>
2840
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2841
+ {permissionMode === id ? <Check className="h-4 w-4" /> : null}
2842
+ </span>
2843
+ </DropdownMenu.Item>
2844
+ ))}
2845
+ </DropdownMenu.Content>
2846
+ </DropdownMenu.Portal>
2847
+ </DropdownMenu.Root>
2848
+
2849
+ <button
2850
+ type="button"
2851
+ onClick={onOpenAddWorkspace}
2852
+ className="inline-flex h-[29px] w-[20px] items-center justify-center text-[var(--vk-text-muted)] hover:text-[var(--vk-text-normal)]"
2853
+ aria-label="Add workspace"
2854
+ >
2855
+ <Paperclip className="h-[18px] w-[18px]" />
2856
+ </button>
2857
+
2858
+ <DropdownMenu.Root>
2859
+ <DropdownMenu.Trigger asChild>
2860
+ <button
2861
+ type="button"
2862
+ disabled={!selectedProject}
2863
+ className="inline-flex min-h-[29px] max-w-[320px] items-center justify-center truncate text-[14px] leading-[21px] text-[var(--vk-text-normal)] hover:text-[var(--vk-text-strong)] disabled:cursor-not-allowed disabled:opacity-50"
2864
+ >
2865
+ {currentProjectLabel}
2866
+ </button>
2867
+ </DropdownMenu.Trigger>
2868
+ <DropdownMenu.Portal>
2869
+ <DropdownMenu.Content
2870
+ align="start"
2871
+ side="bottom"
2872
+ sideOffset={6}
2873
+ avoidCollisions={false}
2874
+ className={scrollMenuClass}
2875
+ >
2876
+ <p className="px-3 pb-1 text-[14px] font-semibold leading-[21px] text-[var(--vk-text-muted)]">Branch</p>
2877
+ {selectedProjectLabel ? (
2878
+ <p className="px-3 pb-2 text-[12px] leading-[16px] text-[var(--text-faint)]">
2879
+ {selectedProjectLabel}
2880
+ </p>
2881
+ ) : null}
2882
+ {branchLoading ? (
2883
+ <div className="px-3 py-2 text-[14px] leading-[21px] text-[var(--vk-text-muted)]">Loading branches...</div>
2884
+ ) : (
2885
+ branchOptions.map((branch) => (
2886
+ <DropdownMenu.Item
2887
+ key={branch}
2888
+ onSelect={() => setSelectedBranch(branch)}
2889
+ className={lightMenuItemClass}
2890
+ >
2891
+ <span>{branch}</span>
2892
+ <span className="ml-auto inline-flex h-4 w-4 items-center justify-center text-[var(--vk-text-strong)]">
2893
+ {selectedBranch === branch ? <Check className="h-4 w-4" /> : null}
2894
+ </span>
2895
+ </DropdownMenu.Item>
2896
+ ))
2897
+ )}
2898
+ </DropdownMenu.Content>
2899
+ </DropdownMenu.Portal>
2900
+ </DropdownMenu.Root>
2901
+ </div>
2902
+
2903
+ <div className="flex w-full justify-end sm:w-auto">
2904
+ <button
2905
+ type="button"
2906
+ onClick={() => onCreate({
2907
+ projectId: effectiveProjectId ?? undefined,
2908
+ ...(useWorktree
2909
+ ? { baseBranch: selectedBranch || selectedProject?.defaultBranch || undefined }
2910
+ : { branch: selectedBranch || selectedProject?.defaultBranch || undefined }),
2911
+ issueId: issueId.trim() || undefined,
2912
+ useWorktree,
2913
+ permissionMode,
2914
+ })}
2915
+ disabled={creating || prompt.trim().length === 0 || !effectiveProjectId}
2916
+ className="inline-flex min-h-[29px] items-center justify-center rounded-[3px] bg-[var(--vk-bg-hover)] px-[8px] py-[6.5px] text-[16px] leading-[16px] text-[var(--vk-text-strong)] transition-colors hover:bg-[var(--vk-bg-active)] disabled:cursor-not-allowed disabled:opacity-50"
2917
+ >
2918
+ {creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
2919
+ </button>
2920
+ </div>
2921
+ </div>
2922
+
2923
+ {selectedAgentState && !selectedAgentState.ready ? (
2924
+ <div className="rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-main)] px-3 py-2 text-[13px] text-[var(--vk-text-normal)]">
2925
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
2926
+ <div className="min-w-0">
2927
+ <p className="text-[13px] text-[var(--vk-text-strong)]">
2928
+ {selectedAgentLabel} is not ready on this machine.
2929
+ </p>
2930
+ <p className="pt-0.5 text-[12px] text-[var(--vk-text-muted)]">
2931
+ {selectedAgentState.installed
2932
+ ? "Finish login or local setup to load models and start streaming sessions."
2933
+ : "Install the CLI first, then its models and authentication state will appear here."}
2934
+ </p>
2935
+ </div>
2936
+ <button
2937
+ type="button"
2938
+ onClick={() => onOpenAgentSetup(selectedAgent)}
2939
+ className="inline-flex h-[29px] items-center justify-center rounded-[3px] border border-[var(--vk-border)] px-3 text-[12px] text-[var(--vk-orange)] hover:bg-[var(--vk-bg-hover)]"
2940
+ >
2941
+ {selectedAgentState.installed ? "Open setup" : "Open install"}
2942
+ </button>
2943
+ </div>
2944
+ </div>
2945
+ ) : null}
2946
+
2947
+ <label className="flex items-start gap-2 rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-main)] px-2 py-2 text-[13px] text-[var(--vk-text-normal)]">
2948
+ <input
2949
+ type="checkbox"
2950
+ checked={useWorktree}
2951
+ onChange={(event) => setUseWorktree(event.target.checked)}
2952
+ className="mt-0.5 h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
2953
+ />
2954
+ <span>
2955
+ Use worktree isolation
2956
+ <span className="block text-[11px] text-[var(--vk-text-muted)]">
2957
+ If unchecked, the session runs directly on the selected branch in the local repo.
2958
+ </span>
2959
+ </span>
2960
+ </label>
2961
+ </div>
2962
+ </div>
2963
+ </div>
2964
+
2965
+ {error && <p className="pt-2 text-[12px] text-[var(--status-error)]">{error}</p>}
2966
+ </div>
2967
+ </section>
2968
+ );
2969
+ });
2970
+
2971
+ function CopySnippetButton({
2972
+ value,
2973
+ idleLabel = "Copy",
2974
+ copiedLabel = "Copied",
2975
+ }: {
2976
+ value: string;
2977
+ idleLabel?: string;
2978
+ copiedLabel?: string;
2979
+ }) {
2980
+ const [copied, setCopied] = useState(false);
2981
+
2982
+ async function handleCopy() {
2983
+ await navigator.clipboard.writeText(value);
2984
+ setCopied(true);
2985
+ window.setTimeout(() => setCopied(false), 1400);
2986
+ }
2987
+
2988
+ return (
2989
+ <button
2990
+ type="button"
2991
+ onClick={() => void handleCopy()}
2992
+ className="inline-flex h-8 items-center gap-1.5 rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
2993
+ >
2994
+ {copied ? <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" /> : <Copy className="h-3.5 w-3.5" />}
2995
+ <span>{copied ? copiedLabel : idleLabel}</span>
2996
+ </button>
2997
+ );
2998
+ }
2999
+
3000
+ function SettingsDialog({
3001
+ open,
3002
+ mode,
3003
+ creating,
3004
+ error,
3005
+ current,
3006
+ projectCount,
3007
+ agentOptions,
3008
+ runtimeModelCatalogs,
3009
+ onRepositoriesChanged,
3010
+ onOnboardingComplete,
3011
+ onClose,
3012
+ onSave,
3013
+ }: {
3014
+ open: boolean;
3015
+ mode: PreferencesDialogMode;
3016
+ creating: boolean;
3017
+ error: string | null;
3018
+ current: PreferencesPayload;
3019
+ projectCount: number;
3020
+ agentOptions: string[];
3021
+ runtimeModelCatalogs: Record<string, RuntimeAgentModelCatalog>;
3022
+ onRepositoriesChanged?: () => Promise<void>;
3023
+ onOnboardingComplete?: (result: { needsProject: boolean }) => void;
3024
+ onClose: () => void;
3025
+ onSave: (next: PreferencesPayload, options?: { closeDialog?: boolean }) => Promise<boolean>;
3026
+ }) {
3027
+ const [activeTab, setActiveTab] = useState<SettingsTabId>("preferences");
3028
+ const [codingAgent, setCodingAgent] = useState(current.codingAgent);
3029
+ const [ide, setIde] = useState(current.ide);
3030
+ const [remoteSshHost, setRemoteSshHost] = useState(current.remoteSshHost);
3031
+ const [remoteSshUser, setRemoteSshUser] = useState(current.remoteSshUser);
3032
+ const [markdownEditor, setMarkdownEditor] = useState(current.markdownEditor);
3033
+ const [modelAccess, setModelAccess] = useState<ModelAccessPreferences>(current.modelAccess);
3034
+ const [soundEnabled, setSoundEnabled] = useState(current.notifications.soundEnabled);
3035
+ const [soundFile, setSoundFile] = useState<string | null>(current.notifications.soundFile);
3036
+ const [repositories, setRepositories] = useState<RepositorySettingsPayload[]>([]);
3037
+ const [repositoriesLoading, setRepositoriesLoading] = useState(false);
3038
+ const [repositoriesSaving, setRepositoriesSaving] = useState(false);
3039
+ const [repositoriesError, setRepositoriesError] = useState<string | null>(null);
3040
+ const [selectedRepositoryId, setSelectedRepositoryId] = useState("");
3041
+ const [repositoryDraft, setRepositoryDraft] = useState<RepositorySettingsPayload | null>(null);
3042
+ const [repositoryModelSelection, setRepositoryModelSelection] = useState<ModelSelectionState>(emptyModelSelection());
3043
+ const [repositoryBranchOptions, setRepositoryBranchOptions] = useState<string[]>([]);
3044
+ const [repositoryBranchesLoading, setRepositoryBranchesLoading] = useState(false);
3045
+ const [repositoryBranchesError, setRepositoryBranchesError] = useState<string | null>(null);
3046
+ const [repositoryFolderPickerOpen, setRepositoryFolderPickerOpen] = useState(false);
3047
+ const [accessSettings, setAccessSettings] = useState<AccessSettingsPayload>(() => normalizeAccessSettings(null));
3048
+ const [accessLoading, setAccessLoading] = useState(false);
3049
+ const [accessSaving, setAccessSaving] = useState(false);
3050
+ const [accessError, setAccessError] = useState<string | null>(null);
3051
+
3052
+ const isBusy = creating || repositoriesSaving || accessSaving;
3053
+
3054
+ function hydrateRepositoryDraft(value: RepositorySettingsPayload): RepositorySettingsPayload {
3055
+ return {
3056
+ ...value,
3057
+ pathHealth: {
3058
+ exists: value.pathHealth.exists,
3059
+ isGitRepository: value.pathHealth.isGitRepository,
3060
+ suggestedPath: value.pathHealth.suggestedPath,
3061
+ },
3062
+ };
3063
+ }
3064
+
3065
+ function parseMultilineRoleList(value: string): string[] {
3066
+ return value
3067
+ .split(/\n+/g)
3068
+ .map((item) => item.trim())
3069
+ .filter(Boolean);
3070
+ }
3071
+
3072
+ async function loadRepositories(preferredRepositoryId?: string): Promise<void> {
3073
+ setRepositoriesLoading(true);
3074
+ setRepositoriesError(null);
3075
+ try {
3076
+ const res = await fetch("/api/repositories");
3077
+ const data = (await res.json().catch(() => null)) as
3078
+ | { repositories?: RepositorySettingsPayload[]; error?: string }
3079
+ | null;
3080
+ if (!res.ok) {
3081
+ throw new Error(data?.error ?? `Failed to load repositories (${res.status})`);
3082
+ }
3083
+ const items = Array.isArray(data?.repositories) ? data.repositories : [];
3084
+ setRepositories(items);
3085
+
3086
+ const fallbackId = items[0]?.id ?? "";
3087
+ const selectedId = preferredRepositoryId && items.some((item) => item.id === preferredRepositoryId)
3088
+ ? preferredRepositoryId
3089
+ : selectedRepositoryId && items.some((item) => item.id === selectedRepositoryId)
3090
+ ? selectedRepositoryId
3091
+ : fallbackId;
3092
+
3093
+ setSelectedRepositoryId(selectedId);
3094
+ } catch (err) {
3095
+ setRepositories([]);
3096
+ setSelectedRepositoryId("");
3097
+ setRepositoryDraft(null);
3098
+ setRepositoryModelSelection(emptyModelSelection());
3099
+ setRepositoriesError(err instanceof Error ? err.message : "Failed to load repositories");
3100
+ } finally {
3101
+ setRepositoriesLoading(false);
3102
+ }
3103
+ }
3104
+
3105
+ async function detectRepositoryBranches(pathOverride?: string, preferredBranch?: string): Promise<void> {
3106
+ const repositoryPath = pathOverride ?? repositoryDraft?.path ?? "";
3107
+ const trimmedPath = repositoryPath.trim();
3108
+ if (trimmedPath.length === 0) {
3109
+ setRepositoryBranchesError("Select a repository path first.");
3110
+ setRepositoryBranchOptions([]);
3111
+ return;
3112
+ }
3113
+
3114
+ setRepositoryBranchesLoading(true);
3115
+ setRepositoryBranchesError(null);
3116
+ try {
3117
+ const params = new URLSearchParams({ path: trimmedPath });
3118
+ const res = await fetch(`/api/workspaces/branches?${params.toString()}`);
3119
+ const data = (await res.json().catch(() => null)) as
3120
+ | { branches?: string[]; defaultBranch?: string | null; error?: string }
3121
+ | null;
3122
+ if (!res.ok) {
3123
+ throw new Error(data?.error ?? `Failed to detect branches (${res.status})`);
3124
+ }
3125
+
3126
+ const branches = Array.isArray(data?.branches)
3127
+ ? data.branches.filter((branch) => typeof branch === "string" && branch.trim().length > 0)
3128
+ : [];
3129
+ setRepositoryBranchOptions(branches);
3130
+
3131
+ const suggestedDefault = preferredBranch?.trim()
3132
+ || (typeof data?.defaultBranch === "string" && data.defaultBranch.trim().length > 0
3133
+ ? data.defaultBranch.trim()
3134
+ : branches[0] ?? "");
3135
+
3136
+ if (!suggestedDefault) return;
3137
+ setRepositoryDraft((prev) => {
3138
+ if (!prev) return prev;
3139
+ if (prev.defaultBranch.trim().length > 0 && branches.includes(prev.defaultBranch)) {
3140
+ return prev;
3141
+ }
3142
+ return { ...prev, defaultBranch: suggestedDefault };
3143
+ });
3144
+ } catch (err) {
3145
+ setRepositoryBranchOptions([]);
3146
+ setRepositoryBranchesError(err instanceof Error ? err.message : "Failed to detect branches");
3147
+ } finally {
3148
+ setRepositoryBranchesLoading(false);
3149
+ }
3150
+ }
3151
+
3152
+ async function handleSaveRepository(): Promise<boolean> {
3153
+ if (!repositoryDraft || repositoriesSaving) return false;
3154
+ if (repositoryDraft.repo.trim().length === 0 || repositoryDraft.path.trim().length === 0) return false;
3155
+
3156
+ setRepositoriesSaving(true);
3157
+ setRepositoriesError(null);
3158
+ try {
3159
+ const res = await fetch("/api/repositories", {
3160
+ method: "PUT",
3161
+ headers: { "Content-Type": "application/json" },
3162
+ body: JSON.stringify({
3163
+ id: repositoryDraft.id,
3164
+ displayName: repositoryDraft.displayName,
3165
+ repo: repositoryDraft.repo,
3166
+ path: repositoryDraft.path,
3167
+ agent: repositoryDraft.agent,
3168
+ agentModel: resolveModelSelectionValue(repositoryModelSelection) ?? "",
3169
+ agentReasoningEffort: resolveReasoningSelectionValue(repositoryModelSelection) ?? "",
3170
+ defaultWorkingDirectory: repositoryDraft.defaultWorkingDirectory,
3171
+ defaultBranch: repositoryDraft.defaultBranch,
3172
+ devServerScript: repositoryDraft.devServerScript,
3173
+ setupScript: repositoryDraft.setupScript,
3174
+ runSetupInParallel: repositoryDraft.runSetupInParallel,
3175
+ cleanupScript: repositoryDraft.cleanupScript,
3176
+ archiveScript: repositoryDraft.archiveScript,
3177
+ copyFiles: repositoryDraft.copyFiles,
3178
+ }),
3179
+ });
3180
+
3181
+ const data = (await res.json().catch(() => null)) as
3182
+ | { repository?: RepositorySettingsPayload; error?: string }
3183
+ | null;
3184
+ if (!res.ok) {
3185
+ throw new Error(data?.error ?? `Failed to save repository settings (${res.status})`);
3186
+ }
3187
+
3188
+ const saved = data?.repository;
3189
+ if (!saved) {
3190
+ throw new Error("Repository saved but response is missing repository data");
3191
+ }
3192
+
3193
+ setRepositories((prev) => prev.map((item) => (item.id === saved.id ? saved : item)));
3194
+ setRepositoryDraft(hydrateRepositoryDraft(saved));
3195
+ setSelectedRepositoryId(saved.id);
3196
+ setRepositoryBranchesError(null);
3197
+
3198
+ await detectRepositoryBranches(saved.path, saved.defaultBranch);
3199
+
3200
+ if (onRepositoriesChanged) {
3201
+ await onRepositoriesChanged();
3202
+ }
3203
+ return true;
3204
+ } catch (err) {
3205
+ setRepositoriesError(err instanceof Error ? err.message : "Failed to save repository settings");
3206
+ return false;
3207
+ } finally {
3208
+ setRepositoriesSaving(false);
3209
+ }
3210
+ }
3211
+
3212
+ async function loadAccessSettings(): Promise<void> {
3213
+ setAccessLoading(true);
3214
+ setAccessError(null);
3215
+ try {
3216
+ const res = await fetch("/api/access");
3217
+ const data = (await res.json().catch(() => null)) as
3218
+ | { access?: unknown; current?: unknown; error?: string }
3219
+ | null;
3220
+ if (!res.ok) {
3221
+ throw new Error(data?.error ?? `Failed to load organization settings (${res.status})`);
3222
+ }
3223
+ setAccessSettings(normalizeAccessSettings(data?.access, data?.current));
3224
+ } catch (err) {
3225
+ setAccessSettings(normalizeAccessSettings(null));
3226
+ setAccessError(err instanceof Error ? err.message : "Failed to load organization settings");
3227
+ } finally {
3228
+ setAccessLoading(false);
3229
+ }
3230
+ }
3231
+
3232
+ async function handleSaveAccess(): Promise<boolean> {
3233
+ if (accessSaving) return false;
3234
+
3235
+ setAccessSaving(true);
3236
+ setAccessError(null);
3237
+ try {
3238
+ const res = await fetch("/api/access", {
3239
+ method: "PUT",
3240
+ headers: { "Content-Type": "application/json" },
3241
+ body: JSON.stringify({
3242
+ requireAuth: accessSettings.requireAuth,
3243
+ defaultRole: accessSettings.defaultRole,
3244
+ trustedHeaders: {
3245
+ enabled: accessSettings.trustedHeaders.enabled,
3246
+ provider: accessSettings.trustedHeaders.provider,
3247
+ emailHeader: accessSettings.trustedHeaders.emailHeader,
3248
+ jwtHeader: accessSettings.trustedHeaders.jwtHeader,
3249
+ teamDomain: accessSettings.trustedHeaders.teamDomain,
3250
+ audience: accessSettings.trustedHeaders.audience,
3251
+ },
3252
+ roles: {
3253
+ viewers: parseMultilineRoleList(accessSettings.roles.viewers),
3254
+ operators: parseMultilineRoleList(accessSettings.roles.operators),
3255
+ admins: parseMultilineRoleList(accessSettings.roles.admins),
3256
+ viewerDomains: parseMultilineRoleList(accessSettings.roles.viewerDomains),
3257
+ operatorDomains: parseMultilineRoleList(accessSettings.roles.operatorDomains),
3258
+ adminDomains: parseMultilineRoleList(accessSettings.roles.adminDomains),
3259
+ },
3260
+ }),
3261
+ });
3262
+ const data = (await res.json().catch(() => null)) as
3263
+ | { access?: unknown; current?: unknown; error?: string }
3264
+ | null;
3265
+ if (!res.ok) {
3266
+ throw new Error(data?.error ?? `Failed to save organization settings (${res.status})`);
3267
+ }
3268
+
3269
+ setAccessSettings(normalizeAccessSettings(data?.access, data?.current));
3270
+ return true;
3271
+ } catch (err) {
3272
+ setAccessError(err instanceof Error ? err.message : "Failed to save organization settings");
3273
+ return false;
3274
+ } finally {
3275
+ setAccessSaving(false);
3276
+ }
3277
+ }
3278
+
3279
+ useEffect(() => {
3280
+ if (!open) return;
3281
+ setActiveTab(mode === "onboarding" ? "preferences" : "general");
3282
+ setCodingAgent(current.codingAgent);
3283
+ setIde(current.ide);
3284
+ setRemoteSshHost(current.remoteSshHost);
3285
+ setRemoteSshUser(current.remoteSshUser);
3286
+ setMarkdownEditor(current.markdownEditor);
3287
+ setModelAccess(current.modelAccess);
3288
+ setSoundEnabled(current.notifications.soundEnabled);
3289
+ setSoundFile(current.notifications.soundFile);
3290
+ setRepositoryBranchOptions([]);
3291
+ setRepositoryBranchesError(null);
3292
+ setRepositoriesError(null);
3293
+ setRepositoryModelSelection(emptyModelSelection());
3294
+ setAccessError(null);
3295
+ }, [mode, open]);
3296
+
3297
+ useEffect(() => {
3298
+ if (!open) return;
3299
+ if (mode === "settings" || activeTab === "repositories") {
3300
+ void loadRepositories();
3301
+ }
3302
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3303
+ }, [activeTab, mode, open]);
3304
+
3305
+ useEffect(() => {
3306
+ if (!open || mode === "onboarding") return;
3307
+ void loadAccessSettings();
3308
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3309
+ }, [mode, open]);
3310
+
3311
+ useEffect(() => {
3312
+ if (!open) return;
3313
+ if (!selectedRepositoryId) {
3314
+ setRepositoryDraft(null);
3315
+ setRepositoryModelSelection(emptyModelSelection());
3316
+ return;
3317
+ }
3318
+ const selected = repositories.find((item) => item.id === selectedRepositoryId);
3319
+ if (!selected) return;
3320
+ setRepositoryDraft(hydrateRepositoryDraft(selected));
3321
+ setRepositoryModelSelection(
3322
+ buildModelSelection(
3323
+ selected.agent,
3324
+ modelAccess,
3325
+ runtimeModelCatalogs,
3326
+ selected.agentModel,
3327
+ selected.agentReasoningEffort,
3328
+ ),
3329
+ );
3330
+ setRepositoryBranchOptions([]);
3331
+ setRepositoryBranchesError(null);
3332
+ if (selected.path.trim().length > 0) {
3333
+ void detectRepositoryBranches(selected.path, selected.defaultBranch);
3334
+ }
3335
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3336
+ }, [modelAccess, open, repositories, runtimeModelCatalogs, selectedRepositoryId]);
3337
+
3338
+ const onboardingShouldShowRepositoryStep = mode === "onboarding" && projectCount > 0;
3339
+
3340
+ const visibleTabs = useMemo(() => {
3341
+ if (mode === "onboarding") {
3342
+ return onboardingShouldShowRepositoryStep
3343
+ ? ONBOARDING_TABS
3344
+ : ONBOARDING_TABS.filter((tab) => tab.id === "preferences");
3345
+ }
3346
+ return SETTINGS_TABS.filter((tab) => tab.implemented);
3347
+ }, [mode, onboardingShouldShowRepositoryStep]);
3348
+
3349
+ const activeTabItem = visibleTabs.find((tab) => tab.id === activeTab) ?? visibleTabs[0] ?? SETTINGS_TABS[0];
3350
+ const isOnboarding = mode === "onboarding";
3351
+ const isPreferencesTab = activeTabItem.id === "preferences";
3352
+ const isGeneralTab = activeTabItem.id === "general";
3353
+ const isRemoteAccessTab = activeTabItem.id === "remote_access";
3354
+ const isAgentsTab = activeTabItem.id === "agents";
3355
+ const isPreferenceFormTab = isPreferencesTab || isGeneralTab || isRemoteAccessTab || isAgentsTab;
3356
+ const isRepositoriesTab = activeTabItem.id === "repositories";
3357
+ const isOrganizationTab = activeTabItem.id === "organization";
3358
+ const onboardingStepIndex = visibleTabs.findIndex((tab) => tab.id === activeTabItem.id) + 1;
3359
+ const onboardingHasRepositoryStep = visibleTabs.some((tab) => tab.id === "repositories");
3360
+ const accessCanEdit = accessSettings.current.role === "admin";
3361
+
3362
+ const orderedAgentOptions = useMemo(() => {
3363
+ const opts = new Set(agentOptions);
3364
+ if (codingAgent.trim().length > 0) {
3365
+ opts.add(codingAgent);
3366
+ }
3367
+ if (opts.size === 0) {
3368
+ opts.add(DEFAULT_AGENT);
3369
+ }
3370
+ const rankMap = new Map(EXECUTOR_ORDER.map((name, index) => [name, index]));
3371
+ return [...opts].sort((left, right) => {
3372
+ const leftRank = rankMap.get(normalizeAgentName(left)) ?? Number.MAX_SAFE_INTEGER;
3373
+ const rightRank = rankMap.get(normalizeAgentName(right)) ?? Number.MAX_SAFE_INTEGER;
3374
+ if (leftRank !== rightRank) return leftRank - rightRank;
3375
+ return getAgentLabel(left).localeCompare(getAgentLabel(right));
3376
+ });
3377
+ }, [agentOptions, codingAgent]);
3378
+
3379
+ function handleModelAccessChange(agent: string, nextAccess: string) {
3380
+ const catalog = getAgentModelCatalog(agent);
3381
+ if (!catalog) return;
3382
+
3383
+ setModelAccess((prev) => ({
3384
+ ...prev,
3385
+ [catalog.accessKey]: nextAccess,
3386
+ } as ModelAccessPreferences));
3387
+ }
3388
+
3389
+ if (!open) return null;
3390
+
3391
+ const canSubmitPreferences = codingAgent.trim().length > 0
3392
+ && ide.trim().length > 0
3393
+ && markdownEditor.trim().length > 0;
3394
+ const canSaveRepository = !!repositoryDraft
3395
+ && repositoryDraft.displayName.trim().length > 0
3396
+ && repositoryDraft.repo.trim().length > 0
3397
+ && repositoryDraft.path.trim().length > 0
3398
+ && repositoryDraft.defaultBranch.trim().length > 0;
3399
+ const canSaveAccess = accessCanEdit && !accessLoading && (
3400
+ !accessSettings.trustedHeaders.enabled
3401
+ || accessSettings.trustedHeaders.provider === "generic"
3402
+ || (
3403
+ accessSettings.trustedHeaders.teamDomain.trim().length > 0
3404
+ && accessSettings.trustedHeaders.audience.trim().length > 0
3405
+ )
3406
+ );
3407
+ const dialogError = isRepositoriesTab
3408
+ ? repositoriesError
3409
+ : isOrganizationTab
3410
+ ? accessError
3411
+ : error;
3412
+ const accessRoleFields: Array<{
3413
+ label: string;
3414
+ key: keyof AccessSettingsPayload["roles"];
3415
+ placeholder: string;
3416
+ }> = [
3417
+ { label: "Viewer Emails", key: "viewers", placeholder: "alice@example.com" },
3418
+ { label: "Operator Emails", key: "operators", placeholder: "builder@example.com" },
3419
+ { label: "Admin Emails", key: "admins", placeholder: "owner@example.com" },
3420
+ { label: "Viewer Domains", key: "viewerDomains", placeholder: "guests.example.com" },
3421
+ { label: "Operator Domains", key: "operatorDomains", placeholder: "eng.example.com" },
3422
+ { label: "Admin Domains", key: "adminDomains", placeholder: "admins.example.com" },
3423
+ ];
3424
+ const repositoryBootstrapCommand = repositoryDraft
3425
+ ? buildRepositoryBootstrapCommand({
3426
+ ...repositoryDraft,
3427
+ agentModel: resolveModelSelectionValue(repositoryModelSelection) ?? "",
3428
+ agentReasoningEffort: resolveReasoningSelectionValue(repositoryModelSelection) ?? "",
3429
+ }, {
3430
+ ide,
3431
+ markdownEditor,
3432
+ })
3433
+ : "";
3434
+
3435
+ function buildNextPreferences(acknowledgeOnboarding: boolean): PreferencesPayload {
3436
+ const resolvedSoundFile = soundEnabled
3437
+ ? soundFile ?? NOTIFICATION_SOUND_OPTIONS[0]?.id ?? "abstract-sound-4"
3438
+ : null;
3439
+
3440
+ return {
3441
+ onboardingAcknowledged: acknowledgeOnboarding ? true : current.onboardingAcknowledged,
3442
+ codingAgent: codingAgent.trim(),
3443
+ ide: ide.trim(),
3444
+ remoteSshHost: remoteSshHost.trim(),
3445
+ remoteSshUser: remoteSshUser.trim(),
3446
+ markdownEditor: markdownEditor.trim(),
3447
+ modelAccess,
3448
+ notifications: {
3449
+ soundEnabled,
3450
+ soundFile: resolvedSoundFile,
3451
+ },
3452
+ };
3453
+ }
3454
+
3455
+ async function handleSubmitPreferences(
3456
+ acknowledgeOnboarding: boolean,
3457
+ options?: { closeDialog?: boolean },
3458
+ ): Promise<boolean> {
3459
+ if (!canSubmitPreferences || creating) return false;
3460
+ return onSave(buildNextPreferences(acknowledgeOnboarding), options);
3461
+ }
3462
+
3463
+ async function handleOnboardingContinue() {
3464
+ if (repositoriesLoading) return;
3465
+ if (!onboardingHasRepositoryStep) {
3466
+ const saved = await handleSubmitPreferences(true, { closeDialog: true });
3467
+ if (!saved) return;
3468
+ onOnboardingComplete?.({ needsProject: projectCount === 0 });
3469
+ return;
3470
+ }
3471
+
3472
+ const saved = await handleSubmitPreferences(false, { closeDialog: false });
3473
+ if (!saved) return;
3474
+ setActiveTab("repositories");
3475
+ }
3476
+
3477
+ async function handleFinishOnboarding() {
3478
+ if (isRepositoriesTab) {
3479
+ const saved = await handleSaveRepository();
3480
+ if (!saved) return;
3481
+ }
3482
+
3483
+ const saved = await handleSubmitPreferences(true, { closeDialog: true });
3484
+ if (!saved) return;
3485
+ onOnboardingComplete?.({ needsProject: false });
3486
+ }
3487
+
3488
+ return (
3489
+ <>
3490
+ <div
3491
+ className="fixed inset-0 z-[90] flex items-start justify-center overflow-y-auto bg-black/70 px-3 py-3 sm:items-center"
3492
+ onClick={() => {
3493
+ if (isBusy || mode === "onboarding" || repositoryFolderPickerOpen) return;
3494
+ onClose();
3495
+ }}
3496
+ role="presentation"
3497
+ >
3498
+ <div
3499
+ className="flex max-h-[calc(100dvh-1.5rem)] w-full max-w-[1120px] flex-col overflow-hidden rounded-[6px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:h-[min(92vh,760px)] sm:flex-row"
3500
+ onClick={(event) => event.stopPropagation()}
3501
+ >
3502
+ <aside className="flex w-full shrink-0 flex-col border-b border-[var(--vk-border)] bg-[rgba(28,28,28,0.8)] sm:w-[224px] sm:border-b-0 sm:border-r">
3503
+ <header className="border-b border-[var(--vk-border)] px-4 py-3 sm:py-4">
3504
+ <h2 className="text-[22px] leading-[24px] text-[var(--vk-text-strong)] sm:text-[27px] sm:leading-[27px]">
3505
+ {isOnboarding ? "Setup" : "Settings"}
3506
+ </h2>
3507
+ </header>
3508
+ <nav className="flex gap-1 overflow-x-auto p-2 sm:block sm:space-y-1 sm:overflow-auto">
3509
+ {visibleTabs.map((tab) => {
3510
+ const Icon = tab.icon;
3511
+ const selected = activeTabItem.id === tab.id;
3512
+ return (
3513
+ <button
3514
+ key={tab.id}
3515
+ type="button"
3516
+ onClick={() => setActiveTab(tab.id)}
3517
+ disabled={isBusy}
3518
+ className={`flex shrink-0 items-center gap-3 rounded-[3px] px-3 py-2 text-left text-[14px] leading-[21px] transition-colors sm:w-full ${
3519
+ selected
3520
+ ? "bg-[rgba(234,122,42,0.1)] text-[var(--vk-orange)]"
3521
+ : "text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
3522
+ } disabled:opacity-50`}
3523
+ >
3524
+ <Icon className="h-4 w-4 shrink-0" />
3525
+ <span>{tab.label}</span>
3526
+ </button>
3527
+ );
3528
+ })}
3529
+ </nav>
3530
+ </aside>
3531
+
3532
+ <div className="flex min-w-0 flex-1 flex-col">
3533
+ <header className="flex items-center justify-between border-b border-[var(--vk-border)] px-4 py-3 sm:py-4">
3534
+ <div>
3535
+ <h3 className="text-[20px] leading-[24px] text-[var(--vk-text-strong)] sm:text-[27px] sm:leading-[27px]">
3536
+ {isOnboarding
3537
+ ? isPreferencesTab
3538
+ ? "Choose your preferences"
3539
+ : "Review repository defaults"
3540
+ : activeTabItem.label}
3541
+ </h3>
3542
+ {isOnboarding && (
3543
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">
3544
+ Step {onboardingStepIndex} of {visibleTabs.length}
3545
+ </p>
3546
+ )}
3547
+ </div>
3548
+ <button
3549
+ type="button"
3550
+ onClick={onClose}
3551
+ disabled={isBusy || mode === "onboarding"}
3552
+ aria-label="Close settings"
3553
+ className="inline-flex h-7 w-7 items-center justify-center rounded-[4px] text-[var(--vk-text-muted)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-40"
3554
+ >
3555
+ <X className="h-4 w-4" />
3556
+ </button>
3557
+ </header>
3558
+
3559
+ <div className="min-h-0 flex-1 overflow-auto px-4 py-3 sm:px-6 sm:py-4">
3560
+ {isPreferenceFormTab ? (
3561
+ <div className="space-y-5">
3562
+ {isOnboarding && (
3563
+ <section className="rounded-[6px] border border-[var(--vk-border)] bg-[rgba(234,122,42,0.08)] px-4 py-3">
3564
+ <p className="text-[13px] leading-5 text-[var(--vk-text-normal)]">
3565
+ Conductor is already running locally. Finish setup here in the dashboard, then you can start using
3566
+ chat and boards immediately.
3567
+ </p>
3568
+ </section>
3569
+ )}
3570
+
3571
+ {(isPreferencesTab || isAgentsTab) && (
3572
+ <>
3573
+ <section className="space-y-2">
3574
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Choose Your Coding Agent</h4>
3575
+ <p className="text-[12px] text-[var(--vk-text-muted)]">Select the default coding agent configuration.</p>
3576
+ <div className="grid gap-2">
3577
+ {orderedAgentOptions.map((agent) => {
3578
+ const selected = codingAgent === agent;
3579
+ return (
3580
+ <button
3581
+ key={agent}
3582
+ type="button"
3583
+ onClick={() => setCodingAgent(agent)}
3584
+ className={`flex items-center gap-2 rounded-[4px] border px-3 py-2 text-left ${
3585
+ selected
3586
+ ? "border-[var(--vk-orange)] bg-[var(--vk-bg-hover)]"
3587
+ : "border-[var(--vk-border)] hover:bg-[var(--vk-bg-hover)]"
3588
+ }`}
3589
+ >
3590
+ <AgentTileIcon seed={{ label: agent }} className="h-5 w-5 border-none bg-transparent" />
3591
+ <span className="flex-1 text-[13px] text-[var(--vk-text-normal)]">{getAgentLabel(agent)}</span>
3592
+ {selected && <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" />}
3593
+ </button>
3594
+ );
3595
+ })}
3596
+ </div>
3597
+ </section>
3598
+
3599
+ <section className="space-y-3">
3600
+ <div className="space-y-1">
3601
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Model Access</h4>
3602
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3603
+ Tell Conductor which account mode each agent is using so the model dropdown only shows options
3604
+ that make sense for that login path.
3605
+ </p>
3606
+ </div>
3607
+ <div className="grid gap-3">
3608
+ {orderedAgentOptions.filter((agent) => supportsAgentModelSelection(agent)).map((agent) => {
3609
+ const catalog = getAgentModelCatalog(agent);
3610
+ if (!catalog) return null;
3611
+ const selectedAccess = resolveAgentModelAccess(agent, modelAccess) ?? catalog.defaultAccess;
3612
+ return (
3613
+ <label key={agent} className="block rounded-[4px] border border-[var(--vk-border)] px-3 py-3">
3614
+ <span className="mb-1 block text-[13px] font-medium text-[var(--vk-text-normal)]">
3615
+ {catalog.label}
3616
+ </span>
3617
+ <select
3618
+ value={selectedAccess}
3619
+ onChange={(event) => handleModelAccessChange(agent, event.target.value)}
3620
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3621
+ >
3622
+ {catalog.accessOptions.map((option) => (
3623
+ <option key={option.id} value={option.id}>
3624
+ {option.label}
3625
+ </option>
3626
+ ))}
3627
+ </select>
3628
+ <p className="mt-1.5 text-[11px] text-[var(--vk-text-muted)]">
3629
+ {catalog.accessOptions.find((option) => option.id === selectedAccess)?.description}
3630
+ </p>
3631
+ </label>
3632
+ );
3633
+ })}
3634
+ </div>
3635
+ </section>
3636
+ </>
3637
+ )}
3638
+
3639
+ {(isPreferencesTab || isGeneralTab) && (
3640
+ <>
3641
+
3642
+ <section className="space-y-2">
3643
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Choose Your Code Editor</h4>
3644
+ <p className="text-[12px] text-[var(--vk-text-muted)]">This editor will be used when opening attempts and files.</p>
3645
+ <div className="grid gap-2 sm:grid-cols-2">
3646
+ {IDE_OPTIONS.map((option) => {
3647
+ const selected = ide === option.id;
3648
+ return (
3649
+ <button
3650
+ key={option.id}
3651
+ type="button"
3652
+ onClick={() => setIde(option.id)}
3653
+ className={`flex items-center gap-2 rounded-[4px] border px-3 py-2 text-left ${
3654
+ selected
3655
+ ? "border-[var(--vk-orange)] bg-[var(--vk-bg-hover)]"
3656
+ : "border-[var(--vk-border)] hover:bg-[var(--vk-bg-hover)]"
3657
+ }`}
3658
+ >
3659
+ <CodeEditorIcon editorId={option.id} label={option.label} />
3660
+ <span className="flex-1 text-[13px] text-[var(--vk-text-normal)]">{option.label}</span>
3661
+ {selected && <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" />}
3662
+ </button>
3663
+ );
3664
+ })}
3665
+ </div>
3666
+ </section>
3667
+
3668
+ {isPreferencesTab && (
3669
+ <section className="space-y-3">
3670
+ <div className="space-y-1">
3671
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Remote Access</h4>
3672
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3673
+ Use your local Remote-SSH editor to jump straight into a remote worktree. This complements
3674
+ ngrok or Cloudflare Tunnel for dashboard access; it does not replace the tunnel.
3675
+ </p>
3676
+ </div>
3677
+
3678
+ <div className="grid gap-3 sm:grid-cols-2">
3679
+ <label className="block">
3680
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">SSH Host or Alias</span>
3681
+ <input
3682
+ value={remoteSshHost}
3683
+ onChange={(event) => setRemoteSshHost(event.target.value)}
3684
+ placeholder="e.g., conductor-dev or 203.0.113.10"
3685
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3686
+ />
3687
+ </label>
3688
+
3689
+ <label className="block">
3690
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">SSH User (optional)</span>
3691
+ <input
3692
+ value={remoteSshUser}
3693
+ onChange={(event) => setRemoteSshUser(event.target.value)}
3694
+ placeholder="e.g., ubuntu"
3695
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3696
+ />
3697
+ </label>
3698
+ </div>
3699
+
3700
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3701
+ One-click remote open currently supports VS Code and VS Code Insiders. Other editors will still
3702
+ save as your preference, but they will not get a remote launch button yet.
3703
+ </p>
3704
+ </section>
3705
+ )}
3706
+
3707
+ <section className="space-y-2">
3708
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Markdown Editor</h4>
3709
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3710
+ Used as your second-brain markdown source when feeding context into tasks.
3711
+ </p>
3712
+ <div className="grid gap-2 sm:grid-cols-2">
3713
+ {MARKDOWN_EDITOR_OPTIONS.map((option) => {
3714
+ const selected = markdownEditor === option.id;
3715
+ return (
3716
+ <button
3717
+ key={option.id}
3718
+ type="button"
3719
+ onClick={() => setMarkdownEditor(option.id)}
3720
+ className={`flex items-center gap-2 rounded-[4px] border px-3 py-2 text-left ${
3721
+ selected
3722
+ ? "border-[var(--vk-orange)] bg-[var(--vk-bg-hover)]"
3723
+ : "border-[var(--vk-border)] hover:bg-[var(--vk-bg-hover)]"
3724
+ }`}
3725
+ >
3726
+ <MarkdownEditorIcon editorId={option.id} />
3727
+ <span className="flex-1 text-[13px] text-[var(--vk-text-normal)]">{option.label}</span>
3728
+ {selected && <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" />}
3729
+ </button>
3730
+ );
3731
+ })}
3732
+ </div>
3733
+ </section>
3734
+
3735
+ <section className="space-y-2">
3736
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Notification Sound</h4>
3737
+ <p className="text-[12px] text-[var(--vk-text-muted)]">Pick a sound for notifications, or disable sound.</p>
3738
+ <div className="grid gap-2 sm:grid-cols-2">
3739
+ {NOTIFICATION_SOUND_OPTIONS.map((option) => {
3740
+ const selected = soundEnabled && soundFile === option.id;
3741
+ return (
3742
+ <button
3743
+ key={option.id}
3744
+ type="button"
3745
+ onClick={() => {
3746
+ setSoundEnabled(true);
3747
+ setSoundFile(option.id);
3748
+ }}
3749
+ className={`flex items-center gap-2 rounded-[4px] border px-3 py-2 text-left ${
3750
+ selected
3751
+ ? "border-[var(--vk-orange)] bg-[var(--vk-bg-hover)]"
3752
+ : "border-[var(--vk-border)] hover:bg-[var(--vk-bg-hover)]"
3753
+ }`}
3754
+ >
3755
+ <Volume2 className="h-4 w-4 text-[var(--vk-text-muted)]" />
3756
+ <span className="flex-1 text-[13px] text-[var(--vk-text-normal)]">{option.label}</span>
3757
+ {selected && <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" />}
3758
+ </button>
3759
+ );
3760
+ })}
3761
+ <button
3762
+ type="button"
3763
+ onClick={() => setSoundEnabled(false)}
3764
+ className={`flex items-center gap-2 rounded-[4px] border px-3 py-2 text-left ${
3765
+ !soundEnabled
3766
+ ? "border-[var(--vk-orange)] bg-[var(--vk-bg-hover)]"
3767
+ : "border-[var(--vk-border)] hover:bg-[var(--vk-bg-hover)]"
3768
+ }`}
3769
+ >
3770
+ <VolumeX className="h-4 w-4 text-[var(--vk-text-muted)]" />
3771
+ <span className="flex-1 text-[13px] text-[var(--vk-text-normal)]">No sound</span>
3772
+ {!soundEnabled && <Check className="h-3.5 w-3.5 text-[var(--vk-orange)]" />}
3773
+ </button>
3774
+ </div>
3775
+ </section>
3776
+ </>
3777
+ )}
3778
+
3779
+ {isRemoteAccessTab && (
3780
+ <section className="space-y-3">
3781
+ <div className="space-y-1">
3782
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Remote Access</h4>
3783
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3784
+ Use your local Remote-SSH editor to jump straight into a remote worktree. This complements
3785
+ ngrok or Cloudflare Tunnel for dashboard access; it does not replace the tunnel.
3786
+ </p>
3787
+ </div>
3788
+
3789
+ <div className="grid gap-3 sm:grid-cols-2">
3790
+ <label className="block">
3791
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">SSH Host or Alias</span>
3792
+ <input
3793
+ value={remoteSshHost}
3794
+ onChange={(event) => setRemoteSshHost(event.target.value)}
3795
+ placeholder="e.g., conductor-dev or 203.0.113.10"
3796
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3797
+ />
3798
+ </label>
3799
+
3800
+ <label className="block">
3801
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">SSH User (optional)</span>
3802
+ <input
3803
+ value={remoteSshUser}
3804
+ onChange={(event) => setRemoteSshUser(event.target.value)}
3805
+ placeholder="e.g., ubuntu"
3806
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3807
+ />
3808
+ </label>
3809
+ </div>
3810
+
3811
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3812
+ One-click remote open currently supports VS Code and VS Code Insiders. Other editors will still
3813
+ save as your preference, but they will not get a remote launch button yet.
3814
+ </p>
3815
+ </section>
3816
+ )}
3817
+ </div>
3818
+ ) : isRepositoriesTab ? (
3819
+ <div className="space-y-5">
3820
+ <section className="space-y-1">
3821
+ <h4 className="text-[24px] leading-[24px] text-[var(--vk-text-strong)]">
3822
+ {isOnboarding ? "Repository Defaults" : "Repository Configuration"}
3823
+ </h4>
3824
+ <p className="text-[14px] text-[var(--vk-text-muted)]">
3825
+ {isOnboarding
3826
+ ? "Review the repository Conductor will use for this workspace. You can edit advanced scripts later from Settings."
3827
+ : "Configure scripts and defaults used whenever this repository is selected for workspaces."}
3828
+ </p>
3829
+ </section>
3830
+
3831
+ {(mode === "settings" || repositories.length > 1) && (
3832
+ <section className="space-y-2">
3833
+ <label className="block">
3834
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Select Repository</span>
3835
+ <select
3836
+ value={selectedRepositoryId}
3837
+ onChange={(event) => setSelectedRepositoryId(event.target.value)}
3838
+ disabled={repositoriesLoading || repositories.length === 0 || isBusy}
3839
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
3840
+ >
3841
+ {repositories.length === 0 && <option value="">No repositories configured</option>}
3842
+ {repositories.map((repository) => (
3843
+ <option key={repository.id} value={repository.id}>
3844
+ {repository.displayName}
3845
+ </option>
3846
+ ))}
3847
+ </select>
3848
+ </label>
3849
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3850
+ Select a repository to view and edit its configuration.
3851
+ </p>
3852
+ {repositoriesLoading && (
3853
+ <p className="text-[12px] text-[var(--vk-text-muted)]">Loading repositories...</p>
3854
+ )}
3855
+ </section>
3856
+ )}
3857
+
3858
+ {isOnboarding && repositories.length === 1 && repositoryDraft && (
3859
+ <label className="block">
3860
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Detected Repository</span>
3861
+ <div className="rounded-[4px] border border-[var(--vk-border)] bg-[rgba(15,15,15,0.52)] px-3 py-3 text-[13px] text-[var(--vk-text-normal)]">
3862
+ {repositoryDraft.displayName}
3863
+ <span className="ml-2 text-[var(--vk-text-muted)]">{repositoryDraft.path}</span>
3864
+ </div>
3865
+ </label>
3866
+ )}
3867
+
3868
+ {repositoryDraft && (
3869
+ <>
3870
+ {mode === "settings" && (
3871
+ <section className="space-y-3 border-t border-[var(--vk-border)] pt-4">
3872
+ <div className="space-y-1">
3873
+ <h5 className="text-[22px] leading-[22px] text-[var(--vk-text-strong)]">Repo-Preseed Bootstrap</h5>
3874
+ <p className="text-[13px] text-[var(--vk-text-muted)]">
3875
+ Use this when you already know the target repository and want one command to prefill it. The
3876
+ default first-run path is still `npx conductor-oss@latest`, which opens the dashboard and lets
3877
+ the user choose preferences before adding a project.
3878
+ </p>
3879
+ </div>
3880
+
3881
+ <div className="flex flex-wrap gap-2 text-[11px] text-[var(--vk-text-muted)]">
3882
+ <span className="rounded-[999px] border border-[var(--vk-border)] px-2 py-1">
3883
+ Workspace: {repositoryDraft.workspaceMode}
3884
+ </span>
3885
+ <span className="rounded-[999px] border border-[var(--vk-border)] px-2 py-1">
3886
+ Runtime: {repositoryDraft.runtimeMode}
3887
+ </span>
3888
+ <span className="rounded-[999px] border border-[var(--vk-border)] px-2 py-1">
3889
+ SCM: {repositoryDraft.scmMode}
3890
+ </span>
3891
+ </div>
3892
+
3893
+ <div className="rounded-[4px] border border-[var(--vk-border)] bg-[rgba(15,15,15,0.72)] p-3">
3894
+ <pre className="overflow-x-auto whitespace-pre-wrap break-all text-[12px] leading-5 text-[var(--vk-text-normal)]">
3895
+ {repositoryBootstrapCommand}
3896
+ </pre>
3897
+ </div>
3898
+
3899
+ <div className="flex flex-wrap items-center justify-between gap-2">
3900
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
3901
+ This command uses your selected agent, editor, and notes app. Best on macOS with Homebrew.
3902
+ GitHub sign-in still opens a browser so the user can approve access.
3903
+ </p>
3904
+ <CopySnippetButton value={repositoryBootstrapCommand} idleLabel="Copy Setup Command" />
3905
+ </div>
3906
+ </section>
3907
+ )}
3908
+
3909
+ <section className="space-y-3 border-t border-[var(--vk-border)] pt-4">
3910
+ <h5 className="text-[22px] leading-[22px] text-[var(--vk-text-strong)]">General Settings</h5>
3911
+ <p className="text-[13px] text-[var(--vk-text-muted)]">Configure basic repository information.</p>
3912
+
3913
+ <label className="block">
3914
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Display Name</span>
3915
+ <input
3916
+ value={repositoryDraft.displayName}
3917
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, displayName: event.target.value } : prev)}
3918
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3919
+ />
3920
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">A friendly name for this repository.</p>
3921
+ </label>
3922
+
3923
+ <label className="block">
3924
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Repository Slug</span>
3925
+ <input
3926
+ value={repositoryDraft.repo}
3927
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, repo: event.target.value } : prev)}
3928
+ placeholder="e.g., your-org/your-repo"
3929
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3930
+ />
3931
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">Used for PR tracking, GitHub links, and onboarding defaults.</p>
3932
+ </label>
3933
+
3934
+ <label className="block">
3935
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Default Agent</span>
3936
+ <select
3937
+ value={repositoryDraft.agent}
3938
+ onChange={(event) => {
3939
+ const nextAgent = event.target.value;
3940
+ setRepositoryDraft((prev) => prev ? { ...prev, agent: nextAgent } : prev);
3941
+ setRepositoryModelSelection(buildModelSelection(nextAgent, modelAccess, runtimeModelCatalogs, null));
3942
+ }}
3943
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3944
+ >
3945
+ {orderedAgentOptions.map((agent) => (
3946
+ <option key={agent} value={agent}>
3947
+ {getAgentLabel(agent)}
3948
+ </option>
3949
+ ))}
3950
+ </select>
3951
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">Used by the one-line bootstrap and as the project default when tasks dispatch.</p>
3952
+ </label>
3953
+
3954
+ {supportsAgentModelSelection(repositoryDraft.agent) && (
3955
+ <div className="rounded-[4px] border border-[var(--vk-border)] px-3 py-3">
3956
+ <AgentModelSelector
3957
+ agent={repositoryDraft.agent}
3958
+ modelAccess={modelAccess}
3959
+ runtimeModelCatalogs={runtimeModelCatalogs}
3960
+ selection={repositoryModelSelection}
3961
+ onChange={setRepositoryModelSelection}
3962
+ />
3963
+ </div>
3964
+ )}
3965
+
3966
+ <label className="block">
3967
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Repository Path</span>
3968
+ <div className="flex items-center gap-2">
3969
+ <input
3970
+ value={repositoryDraft.path}
3971
+ readOnly
3972
+ onClick={() => setRepositoryFolderPickerOpen(true)}
3973
+ placeholder="Use Browse to select a repository folder"
3974
+ className="h-9 w-full cursor-pointer rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
3975
+ />
3976
+ <button
3977
+ type="button"
3978
+ onClick={() => setRepositoryFolderPickerOpen(true)}
3979
+ disabled={isBusy}
3980
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-60"
3981
+ >
3982
+ <FolderOpen className="h-4 w-4" />
3983
+ </button>
3984
+ </div>
3985
+ {!repositoryDraft.pathHealth.exists && (
3986
+ <p className="mt-1 text-[12px] text-[var(--vk-red)]">Configured path does not exist on disk.</p>
3987
+ )}
3988
+ {repositoryDraft.pathHealth.exists && !repositoryDraft.pathHealth.isGitRepository && (
3989
+ <p className="mt-1 text-[12px] text-[var(--vk-red)]">Configured path exists but is not a git repository.</p>
3990
+ )}
3991
+ {repositoryDraft.pathHealth.suggestedPath && (
3992
+ <button
3993
+ type="button"
3994
+ onClick={() => {
3995
+ const suggestedPath = repositoryDraft.pathHealth.suggestedPath ?? "";
3996
+ if (!suggestedPath) return;
3997
+ setRepositoryDraft((prev) => prev
3998
+ ? {
3999
+ ...prev,
4000
+ path: suggestedPath,
4001
+ pathHealth: {
4002
+ ...prev.pathHealth,
4003
+ exists: true,
4004
+ isGitRepository: true,
4005
+ suggestedPath: null,
4006
+ },
4007
+ }
4008
+ : prev);
4009
+ void detectRepositoryBranches(suggestedPath);
4010
+ }}
4011
+ className="mt-1 inline-flex h-7 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[11px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
4012
+ >
4013
+ Use detected git repo path
4014
+ </button>
4015
+ )}
4016
+ </label>
4017
+
4018
+ <label className="block">
4019
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Default Working Directory</span>
4020
+ <input
4021
+ value={repositoryDraft.defaultWorkingDirectory}
4022
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, defaultWorkingDirectory: event.target.value } : prev)}
4023
+ placeholder="e.g., packages/frontend"
4024
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4025
+ />
4026
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">
4027
+ Subdirectory relative to the repository root where the coding agent starts.
4028
+ </p>
4029
+ </label>
4030
+
4031
+ <label className="block">
4032
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Default Target Branch</span>
4033
+ <div className="flex items-center gap-2">
4034
+ <input
4035
+ value={repositoryDraft.defaultBranch}
4036
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, defaultBranch: event.target.value } : prev)}
4037
+ placeholder="Select a branch"
4038
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[14px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4039
+ />
4040
+ <button
4041
+ type="button"
4042
+ onClick={() => {
4043
+ void detectRepositoryBranches();
4044
+ }}
4045
+ disabled={repositoryBranchesLoading}
4046
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-2 text-[12px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-60"
4047
+ title="Detect branches"
4048
+ >
4049
+ {repositoryBranchesLoading ? (
4050
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
4051
+ ) : (
4052
+ <RefreshCcw className="h-3.5 w-3.5" />
4053
+ )}
4054
+ </button>
4055
+ </div>
4056
+ {repositoryBranchOptions.length > 0 && (
4057
+ <select
4058
+ value={repositoryDraft.defaultBranch}
4059
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, defaultBranch: event.target.value } : prev)}
4060
+ className="mt-2 h-8 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[12px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4061
+ >
4062
+ {repositoryBranchOptions.map((branch) => (
4063
+ <option key={branch} value={branch}>
4064
+ {branch}
4065
+ </option>
4066
+ ))}
4067
+ </select>
4068
+ )}
4069
+ {repositoryBranchesError && (
4070
+ <p className="mt-1 text-[12px] text-[var(--vk-red)]">{repositoryBranchesError}</p>
4071
+ )}
4072
+ </label>
4073
+ </section>
4074
+
4075
+ {mode === "settings" && (
4076
+ <section className="space-y-3 border-t border-[var(--vk-border)] pt-4">
4077
+ <h5 className="text-[22px] leading-[22px] text-[var(--vk-text-strong)]">Scripts & Configuration</h5>
4078
+ <p className="text-[13px] text-[var(--vk-text-muted)]">
4079
+ Configure dev server, setup, cleanup, archive, and file-copy behavior for this repository.
4080
+ </p>
4081
+
4082
+ <label className="block">
4083
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Dev Server Script</span>
4084
+ <textarea
4085
+ rows={3}
4086
+ value={repositoryDraft.devServerScript}
4087
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, devServerScript: event.target.value } : prev)}
4088
+ placeholder="npm run dev"
4089
+ className="w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 py-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4090
+ />
4091
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">Starts a development server for this repository.</p>
4092
+ </label>
4093
+
4094
+ <label className="block">
4095
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Setup Script</span>
4096
+ <textarea
4097
+ rows={4}
4098
+ value={repositoryDraft.setupScript}
4099
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, setupScript: event.target.value } : prev)}
4100
+ placeholder="npm install"
4101
+ className="w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 py-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4102
+ />
4103
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">
4104
+ Runs in the worktree after creation and before/with coding-agent startup.
4105
+ </p>
4106
+ </label>
4107
+
4108
+ <label className="flex items-start gap-2 rounded-[4px] border border-[var(--vk-border)] px-3 py-2 text-[13px] text-[var(--vk-text-normal)]">
4109
+ <input
4110
+ type="checkbox"
4111
+ checked={repositoryDraft.runSetupInParallel}
4112
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, runSetupInParallel: event.target.checked } : prev)}
4113
+ className="mt-0.5 h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
4114
+ />
4115
+ <span>Run setup script in parallel with coding agent</span>
4116
+ </label>
4117
+
4118
+ <label className="block">
4119
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Cleanup Script</span>
4120
+ <textarea
4121
+ rows={4}
4122
+ value={repositoryDraft.cleanupScript}
4123
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, cleanupScript: event.target.value } : prev)}
4124
+ placeholder="Runs when the workspace is archived and changes exist"
4125
+ className="w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 py-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4126
+ />
4127
+ </label>
4128
+
4129
+ <label className="block">
4130
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Archive Script</span>
4131
+ <textarea
4132
+ rows={4}
4133
+ value={repositoryDraft.archiveScript}
4134
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, archiveScript: event.target.value } : prev)}
4135
+ placeholder="Runs when the workspace/session is archived"
4136
+ className="w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 py-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4137
+ />
4138
+ </label>
4139
+
4140
+ <label className="block">
4141
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Copy Files</span>
4142
+ <input
4143
+ value={repositoryDraft.copyFiles}
4144
+ onChange={(event) => setRepositoryDraft((prev) => prev ? { ...prev, copyFiles: event.target.value } : prev)}
4145
+ placeholder=".env, config/*.json"
4146
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)]"
4147
+ />
4148
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">
4149
+ Comma-separated relative file paths or glob patterns copied from the repo to each worktree.
4150
+ </p>
4151
+ </label>
4152
+ </section>
4153
+ )}
4154
+ </>
4155
+ )}
4156
+ </div>
4157
+ ) : isOrganizationTab ? (
4158
+ <div className="space-y-5">
4159
+ <section className="rounded-[6px] border border-[var(--vk-border)] bg-[rgba(234,122,42,0.06)] px-4 py-3">
4160
+ <h4 className="text-[15px] font-medium text-[var(--vk-text-strong)]">Security-First Remote Access</h4>
4161
+ <p className="mt-1 text-[12px] leading-5 text-[var(--vk-text-muted)]">
4162
+ The dashboard stays bound to localhost. For phone and team access, put a verified edge
4163
+ identity layer like Cloudflare Access in front of it, then map authenticated users into
4164
+ viewer, operator, or admin roles here.
4165
+ </p>
4166
+ </section>
4167
+
4168
+ {accessLoading ? (
4169
+ <section className="flex items-center gap-2 rounded-[6px] border border-[var(--vk-border)] px-4 py-4 text-[13px] text-[var(--vk-text-muted)]">
4170
+ <Loader2 className="h-4 w-4 animate-spin" />
4171
+ Loading organization access settings...
4172
+ </section>
4173
+ ) : (
4174
+ <>
4175
+ <section className="grid gap-3 lg:grid-cols-3">
4176
+ <div className="rounded-[6px] border border-[var(--vk-border)] px-4 py-3">
4177
+ <span className="text-[11px] uppercase tracking-[0.12em] text-[var(--vk-text-muted)]">
4178
+ Current Identity
4179
+ </span>
4180
+ <p className="mt-2 text-[14px] text-[var(--vk-text-normal)]">
4181
+ {accessSettings.current.email ?? "Anonymous local session"}
4182
+ </p>
4183
+ </div>
4184
+ <div className="rounded-[6px] border border-[var(--vk-border)] px-4 py-3">
4185
+ <span className="text-[11px] uppercase tracking-[0.12em] text-[var(--vk-text-muted)]">
4186
+ Effective Role
4187
+ </span>
4188
+ <p className="mt-2 text-[14px] text-[var(--vk-text-normal)]">
4189
+ {accessSettings.current.role ?? "No access"}
4190
+ </p>
4191
+ </div>
4192
+ <div className="rounded-[6px] border border-[var(--vk-border)] px-4 py-3">
4193
+ <span className="text-[11px] uppercase tracking-[0.12em] text-[var(--vk-text-muted)]">
4194
+ Auth Provider
4195
+ </span>
4196
+ <p className="mt-2 text-[14px] text-[var(--vk-text-normal)]">
4197
+ {accessSettings.current.provider ?? "Local only"}
4198
+ </p>
4199
+ </div>
4200
+ </section>
4201
+
4202
+ {!accessCanEdit && (
4203
+ <section className="rounded-[6px] border border-[var(--vk-border)] bg-[rgba(80,80,80,0.18)] px-4 py-3">
4204
+ <p className="text-[12px] leading-5 text-[var(--vk-text-muted)]">
4205
+ You can review organization security here, but only an admin session can save changes.
4206
+ Use the built-in unlock link, a local admin session, or an admin identity from your edge
4207
+ auth provider to modify access rules.
4208
+ </p>
4209
+ </section>
4210
+ )}
4211
+
4212
+ <section className="space-y-3 rounded-[6px] border border-[var(--vk-border)] px-4 py-4">
4213
+ <div className="space-y-1">
4214
+ <h5 className="text-[18px] leading-[20px] text-[var(--vk-text-strong)]">Baseline Access Rules</h5>
4215
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
4216
+ Require authentication for every dashboard request and decide what authenticated users get
4217
+ by default before explicit role bindings are applied.
4218
+ </p>
4219
+ </div>
4220
+
4221
+ <label className="flex items-start gap-2 rounded-[4px] border border-[var(--vk-border)] px-3 py-2 text-[13px] text-[var(--vk-text-normal)]">
4222
+ <input
4223
+ type="checkbox"
4224
+ checked={accessSettings.requireAuth}
4225
+ onChange={(event) => setAccessSettings((prev) => ({
4226
+ ...prev,
4227
+ requireAuth: event.target.checked,
4228
+ }))}
4229
+ disabled={!accessCanEdit || accessSaving}
4230
+ className="mt-0.5 h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
4231
+ />
4232
+ <span>Require authentication even on localhost</span>
4233
+ </label>
4234
+
4235
+ <label className="block">
4236
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Default Role</span>
4237
+ <select
4238
+ value={accessSettings.defaultRole}
4239
+ onChange={(event) => setAccessSettings((prev) => ({
4240
+ ...prev,
4241
+ defaultRole: event.target.value as DashboardRole,
4242
+ }))}
4243
+ disabled={!accessCanEdit || accessSaving}
4244
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4245
+ >
4246
+ <option value="viewer">Viewer</option>
4247
+ <option value="operator">Operator</option>
4248
+ <option value="admin">Admin</option>
4249
+ </select>
4250
+ <p className="mt-1 text-[12px] text-[var(--vk-text-muted)]">
4251
+ This applies after identity verification when no explicit email or domain binding matches.
4252
+ </p>
4253
+ </label>
4254
+ </section>
4255
+
4256
+ <section className="space-y-3 rounded-[6px] border border-[var(--vk-border)] px-4 py-4">
4257
+ <div className="space-y-1">
4258
+ <h5 className="text-[18px] leading-[20px] text-[var(--vk-text-strong)]">Verified Edge Auth</h5>
4259
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
4260
+ Recommended for secure public phone access and free team collaboration. Conductor verifies
4261
+ the Cloudflare Access JWT instead of trusting a raw email header.
4262
+ </p>
4263
+ </div>
4264
+
4265
+ <label className="flex items-start gap-2 rounded-[4px] border border-[var(--vk-border)] px-3 py-2 text-[13px] text-[var(--vk-text-normal)]">
4266
+ <input
4267
+ type="checkbox"
4268
+ checked={accessSettings.trustedHeaders.enabled}
4269
+ onChange={(event) => setAccessSettings((prev) => ({
4270
+ ...prev,
4271
+ trustedHeaders: {
4272
+ ...prev.trustedHeaders,
4273
+ enabled: event.target.checked,
4274
+ },
4275
+ }))}
4276
+ disabled={!accessCanEdit || accessSaving}
4277
+ className="mt-0.5 h-4 w-4 rounded border border-[var(--vk-border)] bg-transparent accent-[var(--vk-orange)]"
4278
+ />
4279
+ <span>Enable verified Cloudflare Access authentication</span>
4280
+ </label>
4281
+
4282
+ <div className="grid gap-3 lg:grid-cols-2">
4283
+ <label className="block">
4284
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Provider</span>
4285
+ <select
4286
+ value={accessSettings.trustedHeaders.provider}
4287
+ onChange={(event) => setAccessSettings((prev) => ({
4288
+ ...prev,
4289
+ trustedHeaders: {
4290
+ ...prev.trustedHeaders,
4291
+ provider: event.target.value as TrustedHeaderAccessProvider,
4292
+ },
4293
+ }))}
4294
+ disabled={!accessCanEdit || accessSaving}
4295
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-[var(--vk-bg-panel)] px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4296
+ >
4297
+ <option value="cloudflare-access">Cloudflare Access (verified JWT)</option>
4298
+ <option value="generic">Generic header passthrough (advanced)</option>
4299
+ </select>
4300
+ </label>
4301
+
4302
+ <label className="block">
4303
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Identity Email Header</span>
4304
+ <input
4305
+ value={accessSettings.trustedHeaders.emailHeader}
4306
+ onChange={(event) => setAccessSettings((prev) => ({
4307
+ ...prev,
4308
+ trustedHeaders: {
4309
+ ...prev.trustedHeaders,
4310
+ emailHeader: event.target.value,
4311
+ },
4312
+ }))}
4313
+ disabled={!accessCanEdit || accessSaving}
4314
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4315
+ />
4316
+ </label>
4317
+
4318
+ <label className="block">
4319
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">JWT Assertion Header</span>
4320
+ <input
4321
+ value={accessSettings.trustedHeaders.jwtHeader}
4322
+ onChange={(event) => setAccessSettings((prev) => ({
4323
+ ...prev,
4324
+ trustedHeaders: {
4325
+ ...prev.trustedHeaders,
4326
+ jwtHeader: event.target.value,
4327
+ },
4328
+ }))}
4329
+ disabled={!accessCanEdit || accessSaving}
4330
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4331
+ />
4332
+ </label>
4333
+
4334
+ <label className="block">
4335
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Cloudflare Team Domain</span>
4336
+ <input
4337
+ value={accessSettings.trustedHeaders.teamDomain}
4338
+ onChange={(event) => setAccessSettings((prev) => ({
4339
+ ...prev,
4340
+ trustedHeaders: {
4341
+ ...prev.trustedHeaders,
4342
+ teamDomain: event.target.value,
4343
+ },
4344
+ }))}
4345
+ disabled={!accessCanEdit || accessSaving}
4346
+ placeholder="your-team.cloudflareaccess.com"
4347
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4348
+ />
4349
+ </label>
4350
+
4351
+ <label className="block lg:col-span-2">
4352
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">Cloudflare Access Audience</span>
4353
+ <input
4354
+ value={accessSettings.trustedHeaders.audience}
4355
+ onChange={(event) => setAccessSettings((prev) => ({
4356
+ ...prev,
4357
+ trustedHeaders: {
4358
+ ...prev.trustedHeaders,
4359
+ audience: event.target.value,
4360
+ },
4361
+ }))}
4362
+ disabled={!accessCanEdit || accessSaving}
4363
+ placeholder="Copy the AUD value from your Cloudflare Access application"
4364
+ className="h-9 w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4365
+ />
4366
+ </label>
4367
+ </div>
4368
+
4369
+ {accessSettings.trustedHeaders.provider === "generic" && (
4370
+ <p className="rounded-[4px] border border-[var(--vk-red)]/35 bg-[var(--vk-red)]/10 px-3 py-2 text-[12px] leading-5 text-[var(--vk-red)]">
4371
+ Generic header passthrough is only safe when your reverse proxy strips user-supplied headers
4372
+ and injects identity itself. Conductor blocks this mode by default unless
4373
+ `CONDUCTOR_ALLOW_INSECURE_TRUSTED_HEADERS=true` is also set.
4374
+ </p>
4375
+ )}
4376
+ </section>
4377
+
4378
+ <section className="space-y-3 rounded-[6px] border border-[var(--vk-border)] px-4 py-4">
4379
+ <div className="space-y-1">
4380
+ <h5 className="text-[18px] leading-[20px] text-[var(--vk-text-strong)]">Role Bindings</h5>
4381
+ <p className="text-[12px] text-[var(--vk-text-muted)]">
4382
+ Map verified team identities into least-privilege roles. `viewer` can inspect work, `operator`
4383
+ can control agents, and `admin` can change global settings.
4384
+ </p>
4385
+ </div>
4386
+
4387
+ <div className="grid gap-3 lg:grid-cols-2">
4388
+ {accessRoleFields.map(({ label, key, placeholder }) => (
4389
+ <label key={key} className="block">
4390
+ <span className="mb-1.5 block text-[12px] font-medium text-[var(--vk-text-normal)]">{label}</span>
4391
+ <textarea
4392
+ rows={4}
4393
+ value={accessSettings.roles[key]}
4394
+ onChange={(event) => setAccessSettings((prev) => ({
4395
+ ...prev,
4396
+ roles: {
4397
+ ...prev.roles,
4398
+ [key]: event.target.value,
4399
+ },
4400
+ }))}
4401
+ disabled={!accessCanEdit || accessSaving}
4402
+ placeholder={placeholder}
4403
+ className="w-full rounded-[4px] border border-[var(--vk-border)] bg-transparent px-2 py-2 text-[13px] text-[var(--vk-text-normal)] outline-none focus:border-[var(--vk-orange)] disabled:opacity-60"
4404
+ />
4405
+ <p className="mt-1 text-[11px] text-[var(--vk-text-muted)]">One entry per line.</p>
4406
+ </label>
4407
+ ))}
4408
+ </div>
4409
+ </section>
4410
+ </>
4411
+ )}
4412
+ </div>
4413
+ ) : (
4414
+ <section className="space-y-3">
4415
+ <h4 className="text-[16px] font-medium text-[var(--vk-text-strong)]">{activeTabItem.label}</h4>
4416
+ <p className="text-[14px] text-[var(--vk-text-muted)]">
4417
+ This section is queued for implementation. General, Agents, Remote Access, and repository settings are available now.
4418
+ </p>
4419
+ <button
4420
+ type="button"
4421
+ onClick={() => setActiveTab("general")}
4422
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-3 text-[13px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)]"
4423
+ >
4424
+ Open General
4425
+ </button>
4426
+ </section>
4427
+ )}
4428
+ </div>
4429
+
4430
+ <footer className="flex flex-col gap-3 border-t border-[var(--vk-border)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
4431
+ <div className="min-w-0">
4432
+ {dialogError && (
4433
+ <p className="truncate rounded-[4px] border border-[var(--vk-red)]/35 bg-[var(--vk-red)]/10 px-2 py-1 text-[12px] text-[var(--vk-red)]">
4434
+ {dialogError}
4435
+ </p>
4436
+ )}
4437
+ {!dialogError && isPreferenceFormTab && (
4438
+ <p className="text-[11px] text-[var(--vk-text-muted)]">
4439
+ {isOnboarding
4440
+ ? "Finish setup once here. You can change these preferences any time from Settings."
4441
+ : "Preferences are saved to your conductor config and applied immediately."}
4442
+ </p>
4443
+ )}
4444
+ {!dialogError && isRepositoriesTab && (
4445
+ <p className="text-[11px] text-[var(--vk-text-muted)]">
4446
+ {isOnboarding
4447
+ ? "These defaults will be used the first time workspaces and tasks are created for this repo."
4448
+ : "Repository settings are saved to your conductor config and used for future workspaces."}
4449
+ </p>
4450
+ )}
4451
+ {!dialogError && isOrganizationTab && (
4452
+ <p className="text-[11px] text-[var(--vk-text-muted)]">
4453
+ Organization access settings are written into `conductor.yaml`. Use admin role bindings for full
4454
+ control, operator bindings for day-to-day agent usage, and viewer bindings for read-only access.
4455
+ </p>
4456
+ )}
4457
+ </div>
4458
+ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
4459
+ {!isOnboarding && (
4460
+ <button
4461
+ type="button"
4462
+ onClick={onClose}
4463
+ disabled={isBusy}
4464
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-3 text-[13px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4465
+ >
4466
+ Close
4467
+ </button>
4468
+ )}
4469
+ {isOnboarding && isRepositoriesTab && (
4470
+ <button
4471
+ type="button"
4472
+ onClick={() => setActiveTab("preferences")}
4473
+ disabled={isBusy}
4474
+ className="inline-flex h-9 items-center rounded-[4px] border border-[var(--vk-border)] px-3 text-[13px] text-[var(--vk-text-normal)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4475
+ >
4476
+ Back
4477
+ </button>
4478
+ )}
4479
+ {isPreferenceFormTab && !isOnboarding && (
4480
+ <button
4481
+ type="button"
4482
+ onClick={() => {
4483
+ void handleSubmitPreferences(current.onboardingAcknowledged, { closeDialog: true });
4484
+ }}
4485
+ disabled={!canSubmitPreferences || creating}
4486
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4487
+ >
4488
+ {creating ? (
4489
+ <>
4490
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
4491
+ Saving...
4492
+ </>
4493
+ ) : "Save"}
4494
+ </button>
4495
+ )}
4496
+ {isRepositoriesTab && !isOnboarding && (
4497
+ <button
4498
+ type="button"
4499
+ onClick={() => {
4500
+ void handleSaveRepository();
4501
+ }}
4502
+ disabled={!canSaveRepository || repositoriesSaving || repositoriesLoading}
4503
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4504
+ >
4505
+ {repositoriesSaving ? (
4506
+ <>
4507
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
4508
+ Saving...
4509
+ </>
4510
+ ) : "Save Repository"}
4511
+ </button>
4512
+ )}
4513
+ {isOrganizationTab && !isOnboarding && (
4514
+ <button
4515
+ type="button"
4516
+ onClick={() => {
4517
+ void handleSaveAccess();
4518
+ }}
4519
+ disabled={!canSaveAccess || accessSaving}
4520
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4521
+ >
4522
+ {accessSaving ? (
4523
+ <>
4524
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
4525
+ Saving...
4526
+ </>
4527
+ ) : "Save Access"}
4528
+ </button>
4529
+ )}
4530
+ {isOnboarding && (
4531
+ <button
4532
+ type="button"
4533
+ onClick={() => {
4534
+ void (isPreferencesTab ? handleOnboardingContinue() : handleFinishOnboarding());
4535
+ }}
4536
+ disabled={
4537
+ isPreferencesTab
4538
+ ? !canSubmitPreferences || creating || repositoriesLoading
4539
+ : !canSaveRepository || isBusy
4540
+ }
4541
+ className="inline-flex h-9 items-center rounded-[4px] bg-[var(--vk-bg-active)] px-3 text-[13px] text-[var(--vk-text-strong)] hover:bg-[var(--vk-bg-hover)] disabled:opacity-50"
4542
+ >
4543
+ {isBusy || (isPreferencesTab && repositoriesLoading) ? (
4544
+ <>
4545
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
4546
+ {isPreferencesTab && repositoriesLoading ? "Loading..." : "Saving..."}
4547
+ </>
4548
+ ) : isPreferencesTab ? (
4549
+ onboardingHasRepositoryStep ? "Continue" : "Finish Setup"
4550
+ ) : (
4551
+ "Finish Setup"
4552
+ )}
4553
+ </button>
4554
+ )}
4555
+ </div>
4556
+ </footer>
4557
+ </div>
4558
+ </div>
4559
+ </div>
4560
+
4561
+ <FolderPickerDialog
4562
+ open={repositoryFolderPickerOpen}
4563
+ initialPath={repositoryDraft?.path}
4564
+ title="Select Repository Path"
4565
+ description="Choose the local git repository folder."
4566
+ onClose={() => setRepositoryFolderPickerOpen(false)}
4567
+ onSelect={(selectedPath) => {
4568
+ setRepositoryFolderPickerOpen(false);
4569
+ if (!selectedPath) return;
4570
+ setRepositoryDraft((prev) => prev
4571
+ ? {
4572
+ ...prev,
4573
+ path: selectedPath,
4574
+ pathHealth: {
4575
+ ...prev.pathHealth,
4576
+ exists: true,
4577
+ isGitRepository: true,
4578
+ suggestedPath: null,
4579
+ },
4580
+ }
4581
+ : prev);
4582
+ void detectRepositoryBranches(selectedPath);
4583
+ }}
4584
+ />
4585
+ </>
4586
+ );
4587
+ }