conductor-oss 0.2.16 → 0.2.18

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