@agent-relay/dashboard 2.0.82 → 2.0.84

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 (228) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/1028-da5d75e35d1420f1.js +1 -0
  3. package/out/_next/static/chunks/1528-78b17000a7e10bc6.js +2 -0
  4. package/out/_next/static/chunks/1695-4a5d33ba715e09b4.js +1 -0
  5. package/out/_next/static/chunks/1705-36c2180d00a4a569.js +1 -0
  6. package/out/_next/static/chunks/1dd3208c-e1f87c7b3dc1a820.js +1 -0
  7. package/out/_next/static/chunks/3663-47290254b8f6f5dd.js +1 -0
  8. package/out/_next/static/chunks/3677-4b225baf4801d9b9.js +73 -0
  9. package/out/_next/static/chunks/5118-7e8ada2df38eef07.js +1 -0
  10. package/out/_next/static/chunks/5888-15cbe97c90ed5fae.js +1 -0
  11. package/out/_next/static/chunks/6773-a45343a98df3abb5.js +1 -0
  12. package/out/_next/static/chunks/6940-b824612b605e79b3.js +9 -0
  13. package/out/_next/static/chunks/7894-f4a15249082a680d.js +1 -0
  14. package/out/_next/static/chunks/9175-b3617c1e5cbfed0e.js +1 -0
  15. package/out/_next/static/chunks/9372-1a804b8d08c7a236.js +1 -0
  16. package/out/_next/static/chunks/{ab6c8a12-0a58072fbb505134.js → ab6c8a12-91438a812d94ecf0.js} +1 -1
  17. package/out/_next/static/chunks/app/_not-found/page-8e8842f82d204726.js +1 -0
  18. package/out/_next/static/chunks/app/about/page-b78577a7da8fa459.js +1 -0
  19. package/out/_next/static/chunks/app/app/[[...slug]]/page-3dffd65b6344f53e.js +1 -0
  20. package/out/_next/static/chunks/app/app/onboarding/page-b89be9aa6264a5e1.js +1 -0
  21. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-fbd00893ef69e499.js +1 -0
  22. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-de2ea13649d0b6d3.js +1 -0
  23. package/out/_next/static/chunks/app/blog/page-a08e263c57a156fa.js +1 -0
  24. package/out/_next/static/chunks/app/careers/page-02228e1d6969b232.js +1 -0
  25. package/out/_next/static/chunks/app/changelog/page-1b5c1d79efc6e53a.js +1 -0
  26. package/out/_next/static/chunks/app/cloud/link/page-99654edffffb3af2.js +1 -0
  27. package/out/_next/static/chunks/app/complete-profile/page-59d146e5ddeafc5c.js +1 -0
  28. package/out/_next/static/chunks/app/connect-repos/page-995e16a976a6632c.js +1 -0
  29. package/out/_next/static/chunks/app/contact/page-273396a5ad57bcee.js +1 -0
  30. package/out/_next/static/chunks/app/dev/cli-tools/page-a71b80dcb2d5fc8d.js +1 -0
  31. package/out/_next/static/chunks/app/dev/log-viewer/page-46a6151ae1be0796.js +1 -0
  32. package/out/_next/static/chunks/app/docs/page-7c7cb603b24b7c40.js +1 -0
  33. package/out/_next/static/chunks/app/history/page-0c5cab1dab4e8886.js +1 -0
  34. package/out/_next/static/chunks/app/layout-96d72ba8ef8a43a0.js +1 -0
  35. package/out/_next/static/chunks/app/login/page-0ccbab34213df842.js +1 -0
  36. package/out/_next/static/chunks/app/metrics/page-8616272aeab9c8b0.js +1 -0
  37. package/out/_next/static/chunks/app/page-09ce10603ad9a251.js +1 -0
  38. package/out/_next/static/chunks/app/pricing/page-91c975079120c941.js +1 -0
  39. package/out/_next/static/chunks/app/privacy/{page-c21d51ac2dee3a88.js → page-a49ab271cc686644.js} +1 -1
  40. package/out/_next/static/chunks/app/providers/{page-59114505f4353512.js → page-d775d6eb5bc29e96.js} +1 -1
  41. package/out/_next/static/chunks/app/providers/setup/[provider]/page-ec4ef3cd80de807e.js +1 -0
  42. package/out/_next/static/chunks/app/security/page-d9da9bd9191e8f95.js +1 -0
  43. package/out/_next/static/chunks/app/signup/page-930eca0bf5fd299d.js +1 -0
  44. package/out/_next/static/chunks/app/terms/page-3e4827620b98613c.js +1 -0
  45. package/out/_next/static/chunks/framework-648e1ae7da590300.js +1 -0
  46. package/out/_next/static/chunks/{main-acb1b24265295d6a.js → main-2b1990080c292d92.js} +1 -1
  47. package/out/_next/static/chunks/main-app-9f6b7ff9e754a8f5.js +1 -0
  48. package/out/_next/static/chunks/pages/_app-a077b72e02273ab1.js +1 -0
  49. package/out/_next/static/chunks/pages/_error-84001666436a04e4.js +1 -0
  50. package/out/_next/static/chunks/{webpack-dd93b81e2659669c.js → webpack-7586035f1585f2db.js} +1 -1
  51. package/out/_next/static/css/eb9fc69d1e3d2bed.css +1 -0
  52. package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_buildManifest.js +1 -1
  53. package/out/about.html +2 -2
  54. package/out/about.txt +2 -2
  55. package/out/app/onboarding.html +1 -1
  56. package/out/app/onboarding.txt +2 -2
  57. package/out/app.html +1 -1
  58. package/out/app.txt +2 -2
  59. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +3 -3
  60. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  61. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  62. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  63. package/out/blog.html +2 -2
  64. package/out/blog.txt +1 -1
  65. package/out/careers.html +2 -2
  66. package/out/careers.txt +2 -2
  67. package/out/changelog.html +2 -2
  68. package/out/changelog.txt +2 -2
  69. package/out/cloud/link.html +1 -1
  70. package/out/cloud/link.txt +2 -2
  71. package/out/complete-profile.html +2 -2
  72. package/out/complete-profile.txt +2 -2
  73. package/out/connect-repos.html +1 -1
  74. package/out/connect-repos.txt +2 -2
  75. package/out/contact.html +2 -2
  76. package/out/contact.txt +2 -2
  77. package/out/dev/cli-tools.html +1 -0
  78. package/out/dev/cli-tools.txt +7 -0
  79. package/out/dev/log-viewer.html +23 -0
  80. package/out/dev/log-viewer.txt +7 -0
  81. package/out/docs.html +2 -2
  82. package/out/docs.txt +2 -2
  83. package/out/history.html +1 -1
  84. package/out/history.txt +2 -2
  85. package/out/index.html +1 -1
  86. package/out/index.txt +2 -2
  87. package/out/login.html +2 -2
  88. package/out/login.txt +2 -2
  89. package/out/metrics.html +1 -1
  90. package/out/metrics.txt +2 -2
  91. package/out/pricing.html +2 -2
  92. package/out/pricing.txt +2 -2
  93. package/out/privacy.html +2 -2
  94. package/out/privacy.txt +2 -2
  95. package/out/providers/setup/claude.html +1 -1
  96. package/out/providers/setup/claude.txt +2 -2
  97. package/out/providers/setup/codex.html +1 -1
  98. package/out/providers/setup/codex.txt +2 -2
  99. package/out/providers/setup/cursor.html +1 -1
  100. package/out/providers/setup/cursor.txt +2 -2
  101. package/out/providers.html +1 -1
  102. package/out/providers.txt +2 -2
  103. package/out/security.html +2 -2
  104. package/out/security.txt +2 -2
  105. package/out/signup.html +2 -2
  106. package/out/signup.txt +2 -2
  107. package/out/terms.html +2 -2
  108. package/out/terms.txt +2 -2
  109. package/package.json +5 -1
  110. package/src/adapters/DashboardConfigProvider.tsx +56 -0
  111. package/src/adapters/cloudFetchAdapter.ts +278 -0
  112. package/src/adapters/index.ts +3 -0
  113. package/src/adapters/types.ts +508 -0
  114. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +67 -18
  115. package/src/app/app/onboarding/page.tsx +870 -170
  116. package/src/app/cloud/link/page.tsx +14 -6
  117. package/src/app/connect-repos/page.tsx +9 -3
  118. package/src/app/dev/cli-tools/page.tsx +130 -0
  119. package/src/app/dev/log-viewer/MockLogViewer.tsx +132 -0
  120. package/src/app/dev/log-viewer/fixtures.ts +110 -0
  121. package/src/app/dev/log-viewer/page.tsx +288 -0
  122. package/src/app/history/page.tsx +28 -12
  123. package/src/app/page.tsx +1 -1
  124. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +209 -59
  125. package/src/components/AgentCard.tsx +4 -4
  126. package/src/components/AgentLogPreview.tsx +2 -38
  127. package/src/components/App.tsx +441 -2624
  128. package/src/components/CliToolHarness.test.tsx +83 -0
  129. package/src/components/CliToolHarness.tsx +292 -0
  130. package/src/components/CoordinatorPanel.tsx +13 -6
  131. package/src/components/LogViewer.tsx +2 -42
  132. package/src/components/ProviderAuthFlow.tsx +201 -81
  133. package/src/components/ProvisioningProgress.tsx +1 -1
  134. package/src/components/ReactionChips.tsx +2 -1
  135. package/src/components/SpawnModal.test.tsx +51 -18
  136. package/src/components/SpawnModal.tsx +175 -207
  137. package/src/components/TerminalProviderSetup.tsx +1 -1
  138. package/src/components/ThreadPanel.tsx +2 -0
  139. package/src/components/WorkspaceContext.tsx +7 -19
  140. package/src/components/XTermLogViewer.tsx +190 -27
  141. package/src/components/channels/ChannelMessageList.tsx +94 -4
  142. package/src/components/channels/ChannelViewV1.tsx +35 -11
  143. package/src/components/channels/api.ts +21 -20
  144. package/src/components/channels/types.ts +16 -0
  145. package/src/components/hooks/index.ts +0 -19
  146. package/src/components/hooks/useMessages.test.ts +80 -0
  147. package/src/components/hooks/useMessages.ts +13 -4
  148. package/src/components/hooks/useOrchestrator.ts +1 -1
  149. package/src/components/hooks/usePresence.ts +45 -6
  150. package/src/components/hooks/useThread.ts +83 -46
  151. package/src/components/hooks/useTrajectory.ts +62 -5
  152. package/src/components/hooks/useWebSocket.test.ts +358 -0
  153. package/src/components/hooks/useWebSocket.ts +243 -5
  154. package/src/components/index.ts +2 -14
  155. package/src/components/layout/Header.tsx +9 -15
  156. package/src/components/layout/Sidebar.tsx +1 -8
  157. package/src/components/settings/SettingsPage.tsx +108 -47
  158. package/src/components/settings/index.ts +0 -3
  159. package/src/landing/blogData.ts +1 -1
  160. package/src/lib/agent-merge.test.ts +2 -2
  161. package/src/lib/api.ts +8 -38
  162. package/src/lib/identity.test.ts +139 -0
  163. package/src/lib/identity.ts +48 -0
  164. package/src/lib/relaycastMessageAdapters.test.ts +182 -0
  165. package/src/lib/relaycastMessageAdapters.ts +105 -0
  166. package/src/lib/sanitize-logs.test.ts +227 -0
  167. package/src/lib/sanitize-logs.ts +202 -0
  168. package/src/providers/AgentProvider.tsx +799 -0
  169. package/src/providers/ChannelProvider.tsx +528 -0
  170. package/src/providers/CloudWorkspaceProvider.tsx +402 -0
  171. package/src/providers/MessageProvider.tsx +875 -0
  172. package/src/providers/RelayConfigProvider.tsx +94 -0
  173. package/src/providers/SendProvider.tsx +497 -0
  174. package/src/providers/SettingsProvider.tsx +247 -0
  175. package/src/providers/index.ts +26 -0
  176. package/src/types/index.ts +10 -10
  177. package/out/_next/static/chunks/11-9a2993a37266dcb3.js +0 -9
  178. package/out/_next/static/chunks/118-ae2b650136a5a5fc.js +0 -1
  179. package/out/_next/static/chunks/1dd3208c-40ab0fc0f60392b8.js +0 -1
  180. package/out/_next/static/chunks/202-fc0763dd7488e58f.js +0 -1
  181. package/out/_next/static/chunks/259-83b77fa1b91ba5aa.js +0 -1
  182. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +0 -1
  183. package/out/_next/static/chunks/528-f5f676996d613c25.js +0 -2
  184. package/out/_next/static/chunks/663-ddb04081febc3678.js +0 -1
  185. package/out/_next/static/chunks/687-88b6b139a6bb0e2e.js +0 -1
  186. package/out/_next/static/chunks/695-51d25b1988644374.js +0 -1
  187. package/out/_next/static/chunks/773-54a2641043c81e55.js +0 -1
  188. package/out/_next/static/chunks/app/_not-found/page-6da9b72091e5b511.js +0 -1
  189. package/out/_next/static/chunks/app/about/page-fff7c6457683f243.js +0 -1
  190. package/out/_next/static/chunks/app/app/[[...slug]]/page-f7eca1b66fb4249b.js +0 -1
  191. package/out/_next/static/chunks/app/app/onboarding/page-129abc5da2e67971.js +0 -1
  192. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-5d5f28fd126b692f.js +0 -1
  193. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-b194f207fbd91862.js +0 -1
  194. package/out/_next/static/chunks/app/blog/page-b9bd9d8703fca76a.js +0 -1
  195. package/out/_next/static/chunks/app/careers/page-a4bd8d5f4de8f4eb.js +0 -1
  196. package/out/_next/static/chunks/app/changelog/page-9a1f6ad1743d63c5.js +0 -1
  197. package/out/_next/static/chunks/app/cloud/link/page-0844c5699b027c3b.js +0 -1
  198. package/out/_next/static/chunks/app/complete-profile/page-39ed5a67916beb87.js +0 -1
  199. package/out/_next/static/chunks/app/connect-repos/page-297eddee0c39f2a3.js +0 -1
  200. package/out/_next/static/chunks/app/contact/page-3c1dd8690217fade.js +0 -1
  201. package/out/_next/static/chunks/app/docs/page-1875e981f2c3fd13.js +0 -1
  202. package/out/_next/static/chunks/app/history/page-2d5c5695c9e8b40c.js +0 -1
  203. package/out/_next/static/chunks/app/layout-0a4b99656da25511.js +0 -1
  204. package/out/_next/static/chunks/app/login/page-f69c076f5a6fc520.js +0 -1
  205. package/out/_next/static/chunks/app/metrics/page-bebbee055669a17e.js +0 -1
  206. package/out/_next/static/chunks/app/page-0ee604f7070d14c0.js +0 -1
  207. package/out/_next/static/chunks/app/pricing/page-eeae7d594af333b6.js +0 -1
  208. package/out/_next/static/chunks/app/providers/setup/[provider]/page-daf9b3e05e77ae19.js +0 -1
  209. package/out/_next/static/chunks/app/security/page-cd562730fe84a0a2.js +0 -1
  210. package/out/_next/static/chunks/app/signup/page-c242ca08101a84ff.js +0 -1
  211. package/out/_next/static/chunks/app/terms/page-c7001720e7941dc6.js +0 -1
  212. package/out/_next/static/chunks/framework-3664cab31236a9fa.js +0 -1
  213. package/out/_next/static/chunks/main-app-7f73a939a312a228.js +0 -1
  214. package/out/_next/static/chunks/pages/_app-10a93ab5b7c32eb3.js +0 -1
  215. package/out/_next/static/chunks/pages/_error-2d792b2a41857be4.js +0 -1
  216. package/out/_next/static/css/8968d98ed4c4d33f.css +0 -1
  217. package/src/components/BillingResult.tsx +0 -447
  218. package/src/components/CloudSessionProvider.tsx +0 -130
  219. package/src/components/SessionExpiredModal.tsx +0 -128
  220. package/src/components/WorkspaceStatusIndicator.tsx +0 -396
  221. package/src/components/hooks/useSession.ts +0 -209
  222. package/src/components/hooks/useWorkspaceMembers.ts +0 -132
  223. package/src/components/hooks/useWorkspaceStatus.ts +0 -237
  224. package/src/components/settings/BillingSettingsPanel.tsx +0 -564
  225. package/src/components/settings/TeamSettingsPanel.tsx +0 -560
  226. package/src/components/settings/WorkspaceSettingsPanel.tsx +0 -1368
  227. package/src/lib/cloudApi.ts +0 -893
  228. /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_ssgManifest.js +0 -0
@@ -1,21 +1,18 @@
1
1
  /**
2
2
  * Onboarding Page - Dedicated route for new users and post-deletion flow
3
3
  *
4
- * This page provides a cleaner onboarding experience separate from workspace selection.
5
- * It handles two scenarios:
6
- * 1. First-time users with no workspaces
7
- * 2. Users who just deleted their workspace
8
- *
9
- * URL params:
10
- * - reason=deleted: User just deleted a workspace
11
- * - reason=new: First-time user (default)
4
+ * This page now supports provider-first onboarding after GitHub connection:
5
+ * 1. Determine current onboarding step via /api/onboarding/next-step
6
+ * 2. Connect an AI provider (API key or CLI auth)
7
+ * 3. Auto-create and provision workspace
12
8
  */
13
9
 
14
10
  'use client';
15
11
 
16
- import React, { useState, useEffect, useCallback, Suspense } from 'react';
12
+ import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
13
  import { useSearchParams } from 'next/navigation';
18
14
  import { LogoIcon } from '../../../components/Logo';
15
+ import { ProvisioningProgress } from '../../../components/ProvisioningProgress';
19
16
 
20
17
  interface Repository {
21
18
  id: string;
@@ -26,28 +23,105 @@ interface Repository {
26
23
  hasNangoConnection: boolean;
27
24
  }
28
25
 
26
+ interface NextStepResponse {
27
+ nextStep?: string;
28
+ selectedRepo?: string;
29
+ repositoryFullName?: string;
30
+ repository?: {
31
+ fullName?: string;
32
+ };
33
+ connectedProviders?: string[];
34
+ }
35
+
36
+ async function getOnboardingNextStep(): Promise<NextStepResponse> {
37
+ const response = await fetch('/api/onboarding/next-step', {
38
+ credentials: 'include',
39
+ });
40
+
41
+ if (!response.ok) {
42
+ throw new Error('Failed to fetch onboarding next step');
43
+ }
44
+
45
+ return (await response.json()) as NextStepResponse;
46
+ }
47
+
29
48
  type OnboardingReason = 'new' | 'deleted';
49
+ type ProviderId = 'anthropic' | 'openai' | 'google' | 'cursor' | 'opencode' | 'droid';
50
+ type AuthMode = 'api_key' | 'cli';
51
+ type WorkspaceLifecycleState = 'idle' | 'provisioning' | 'running' | 'error';
30
52
 
31
- // Analytics event types for onboarding funnel
32
53
  type OnboardingEvent =
33
54
  | 'onboarding_page_view'
34
55
  | 'onboarding_repo_selected'
35
56
  | 'onboarding_workspace_created'
36
- | 'onboarding_connect_repos_clicked'
37
- | 'onboarding_skipped';
57
+ | 'onboarding_connect_repos_clicked';
58
+
59
+ interface ProviderOption {
60
+ id: ProviderId;
61
+ label: string;
62
+ description: string;
63
+ color: string;
64
+ }
65
+
66
+ const PROVIDERS: ProviderOption[] = [
67
+ {
68
+ id: 'anthropic',
69
+ label: 'Anthropic',
70
+ description: 'Claude models via Anthropic',
71
+ color: '#D97757',
72
+ },
73
+ {
74
+ id: 'openai',
75
+ label: 'OpenAI',
76
+ description: 'Codex and GPT models',
77
+ color: '#10A37F',
78
+ },
79
+ {
80
+ id: 'cursor',
81
+ label: 'Cursor',
82
+ description: 'Cursor AI editor',
83
+ color: '#7C3AED',
84
+ },
85
+ {
86
+ id: 'google',
87
+ label: 'Google',
88
+ description: 'Gemini models via Google AI',
89
+ color: '#4285F4',
90
+ },
91
+ {
92
+ id: 'opencode',
93
+ label: 'OpenCode',
94
+ description: 'Coming soon',
95
+ color: '#6B7280',
96
+ },
97
+ {
98
+ id: 'droid',
99
+ label: 'Droid',
100
+ description: 'Coming soon',
101
+ color: '#6B7280',
102
+ },
103
+ ];
104
+
105
+ const PROVIDER_STATUS_NAMES: Record<ProviderId, string[]> = {
106
+ anthropic: ['anthropic'],
107
+ openai: ['openai', 'codex'],
108
+ cursor: ['cursor'],
109
+ google: ['google'],
110
+ opencode: ['opencode'],
111
+ droid: ['droid'],
112
+ };
113
+
114
+
115
+ function sleep(ms: number): Promise<void> {
116
+ return new Promise((resolve) => setTimeout(resolve, ms));
117
+ }
38
118
 
39
- // Simple analytics hook - can be extended to integrate with actual analytics service
40
119
  function useOnboardingAnalytics() {
41
120
  const trackEvent = useCallback((event: OnboardingEvent, properties?: Record<string, unknown>) => {
42
- // Log to console in development
43
121
  if (process.env.NODE_ENV === 'development') {
44
122
  console.log('[Onboarding Analytics]', event, properties);
45
123
  }
46
124
 
47
- // TODO: Integrate with actual analytics service (e.g., Posthog, Mixpanel, etc.)
48
- // Example: posthog.capture(event, properties);
49
-
50
- // For now, send to a hypothetical analytics endpoint
51
125
  try {
52
126
  fetch('/api/analytics/track', {
53
127
  method: 'POST',
@@ -55,10 +129,10 @@ function useOnboardingAnalytics() {
55
129
  credentials: 'include',
56
130
  body: JSON.stringify({ event, properties, timestamp: Date.now() }),
57
131
  }).catch(() => {
58
- // Silently fail - analytics should not block user experience
132
+ // Analytics should never block onboarding
59
133
  });
60
134
  } catch {
61
- // Silently fail
135
+ // Analytics should never block onboarding
62
136
  }
63
137
  }, []);
64
138
 
@@ -70,27 +144,282 @@ function OnboardingContent() {
70
144
  const reason = (searchParams.get('reason') as OnboardingReason) || 'new';
71
145
 
72
146
  const [repos, setRepos] = useState<Repository[]>([]);
147
+ const [selectedRepos, setSelectedRepos] = useState<Set<string>>(new Set());
148
+ const [nextStep, setNextStep] = useState<string | null>(null);
149
+ const [selectedProvider, setSelectedProvider] = useState<ProviderId>('anthropic');
150
+ const [authMode, setAuthMode] = useState<AuthMode>('api_key');
151
+ const [apiKey, setApiKey] = useState('');
152
+ const [cliCommand, setCliCommand] = useState<string | null>(null);
153
+
154
+ const [connectedProviders, setConnectedProviders] = useState<Set<ProviderId>>(new Set());
155
+
73
156
  const [isLoading, setIsLoading] = useState(true);
74
- const [isCreating, setIsCreating] = useState(false);
157
+ const [isSubmittingProvider, setIsSubmittingProvider] = useState(false);
158
+ const [isPollingCli, setIsPollingCli] = useState(false);
159
+ const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
160
+
161
+ const [workspaceState, setWorkspaceState] = useState<WorkspaceLifecycleState>('idle');
162
+ const [workspaceId, setWorkspaceId] = useState<string | null>(null);
163
+ const [provisioningStage, setProvisioningStage] = useState<string | null>(null);
164
+
75
165
  const [error, setError] = useState<string | null>(null);
76
166
  const [csrfToken, setCsrfToken] = useState<string | null>(null);
167
+ const [providerFeedback, setProviderFeedback] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null);
168
+ const [copied, setCopied] = useState(false);
169
+
170
+ const cliPollingRef = useRef(false);
171
+ const workspacePollingRef = useRef(false);
77
172
 
78
173
  const { trackEvent } = useOnboardingAnalytics();
79
174
 
80
- // Fetch repositories and check session
175
+ const selectedProviderMeta = useMemo(
176
+ () => PROVIDERS.find((provider) => provider.id === selectedProvider) || PROVIDERS[0],
177
+ [selectedProvider]
178
+ );
179
+
180
+ const buildHeaders = useCallback(
181
+ (includeContentType = true): Record<string, string> => {
182
+ const headers: Record<string, string> = includeContentType ? { 'Content-Type': 'application/json' } : {};
183
+ if (csrfToken) {
184
+ headers['X-CSRF-Token'] = csrfToken;
185
+ }
186
+ return headers;
187
+ },
188
+ [csrfToken]
189
+ );
190
+
191
+ const resolveSelectedRepos = useCallback((repositories: Repository[], stepData: NextStepResponse | null): Set<string> => {
192
+ const stepRepo =
193
+ stepData?.selectedRepo ||
194
+ stepData?.repositoryFullName ||
195
+ stepData?.repository?.fullName ||
196
+ '';
197
+
198
+ if (stepRepo && repositories.some((repo) => repo.fullName === stepRepo)) {
199
+ return new Set([stepRepo]);
200
+ }
201
+
202
+ return repositories.length > 0 ? new Set([repositories[0].fullName]) : new Set();
203
+ }, []);
204
+
205
+ const refreshNextStep = useCallback(async (): Promise<NextStepResponse | null> => {
206
+ try {
207
+ const data = (await getOnboardingNextStep()) as NextStepResponse;
208
+ if (typeof data.nextStep === 'string') {
209
+ setNextStep(data.nextStep);
210
+ }
211
+ return data;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }, []);
216
+
217
+ const verifyProviderConnected = useCallback(
218
+ async (statusWorkspaceId?: string): Promise<boolean> => {
219
+ if (statusWorkspaceId) {
220
+ try {
221
+ const statusRes = await fetch(`/api/auth/ssh/status/${statusWorkspaceId}`, {
222
+ credentials: 'include',
223
+ });
224
+
225
+ if (statusRes.ok) {
226
+ const statusData = (await statusRes.json()) as {
227
+ providers?: Array<{ name: string; status: string }>;
228
+ };
229
+
230
+ const connectedFromStatus = PROVIDER_STATUS_NAMES[selectedProvider].some((providerName) =>
231
+ statusData.providers?.some((provider) => provider.name === providerName && provider.status === 'connected')
232
+ );
233
+
234
+ if (connectedFromStatus) {
235
+ return true;
236
+ }
237
+ }
238
+ } catch {
239
+ // Fall through to onboarding next-step check.
240
+ }
241
+ }
242
+
243
+ const nextStepData = await refreshNextStep();
244
+ if (!nextStepData) {
245
+ return false;
246
+ }
247
+
248
+ if (nextStepData.nextStep && nextStepData.nextStep !== 'connect_ai_provider') {
249
+ return true;
250
+ }
251
+
252
+ const connectedProviders = Array.isArray(nextStepData.connectedProviders)
253
+ ? nextStepData.connectedProviders
254
+ : [];
255
+
256
+ return PROVIDER_STATUS_NAMES[selectedProvider].some((providerName) => connectedProviders.includes(providerName));
257
+ },
258
+ [refreshNextStep, selectedProvider]
259
+ );
260
+
261
+ const pollWorkspaceUntilRunning = useCallback(async (createdWorkspaceId: string): Promise<void> => {
262
+ workspacePollingRef.current = true;
263
+
264
+ try {
265
+ const maxAttempts = 150; // 5 minutes at 2s intervals
266
+ let attempts = 0;
267
+ let consecutiveErrors = 0;
268
+
269
+ while (workspacePollingRef.current && attempts < maxAttempts) {
270
+ try {
271
+ const statusRes = await fetch(`/api/workspaces/${createdWorkspaceId}/status`, {
272
+ credentials: 'include',
273
+ });
274
+
275
+ if (!statusRes.ok) {
276
+ consecutiveErrors++;
277
+ console.warn(`[onboarding] Status poll returned ${statusRes.status} (attempt ${consecutiveErrors})`);
278
+ if (consecutiveErrors >= 5) {
279
+ throw new Error(`Workspace status check failed (HTTP ${statusRes.status})`);
280
+ }
281
+ await sleep(2000);
282
+ attempts += 1;
283
+ continue;
284
+ }
285
+
286
+ consecutiveErrors = 0;
287
+
288
+ const statusData = (await statusRes.json()) as {
289
+ status?: string;
290
+ errorMessage?: string;
291
+ provisioning?: { stage?: string | null };
292
+ };
293
+
294
+ if (statusData.provisioning?.stage) {
295
+ setProvisioningStage(statusData.provisioning.stage);
296
+ }
297
+
298
+ if (statusData.status === 'running') {
299
+ setWorkspaceState('running');
300
+ return;
301
+ }
302
+
303
+ if (statusData.status === 'error' || statusData.status === 'stopped') {
304
+ throw new Error(statusData.errorMessage || `Workspace ${statusData.status === 'stopped' ? 'stopped unexpectedly' : 'provisioning failed'}`);
305
+ }
306
+ } catch (fetchErr) {
307
+ // Re-throw our intentional errors
308
+ if (fetchErr instanceof Error && (
309
+ fetchErr.message.includes('provisioning failed') ||
310
+ fetchErr.message.includes('stopped unexpectedly') ||
311
+ fetchErr.message.includes('status check failed')
312
+ )) {
313
+ throw fetchErr;
314
+ }
315
+ // Network errors — keep trying
316
+ consecutiveErrors++;
317
+ console.warn(`[onboarding] Status poll network error (attempt ${consecutiveErrors}):`, fetchErr);
318
+ if (consecutiveErrors >= 5) {
319
+ throw new Error('Unable to reach workspace status endpoint');
320
+ }
321
+ }
322
+
323
+ await sleep(2000);
324
+ attempts += 1;
325
+ }
326
+
327
+ throw new Error('Workspace provisioning timed out after 5 minutes.');
328
+ } finally {
329
+ workspacePollingRef.current = false;
330
+ }
331
+ }, []);
332
+
333
+ const startWorkspaceCreation = useCallback(
334
+ async (repoNames?: string[]) => {
335
+ const repositories = repoNames && repoNames.length > 0 ? repoNames : Array.from(selectedRepos);
336
+
337
+ if (repositories.length === 0 || isCreatingWorkspace || workspaceState === 'provisioning' || workspaceState === 'running') {
338
+ return;
339
+ }
340
+
341
+ setError(null);
342
+ setWorkspaceState('provisioning');
343
+ setProvisioningStage(null);
344
+ setIsCreatingWorkspace(true);
345
+
346
+ trackEvent('onboarding_repo_selected', { repositories });
347
+
348
+ try {
349
+ const res = await fetch('/api/workspaces/quick', {
350
+ method: 'POST',
351
+ credentials: 'include',
352
+ headers: buildHeaders(),
353
+ body: JSON.stringify({ repositories }),
354
+ });
355
+
356
+ const data = await res.json();
357
+
358
+ if (!res.ok) {
359
+ throw new Error(data.error || 'Failed to create workspace');
360
+ }
361
+
362
+ if (!data.workspaceId || typeof data.workspaceId !== 'string') {
363
+ throw new Error('Workspace created but no workspace ID was returned');
364
+ }
365
+
366
+ setWorkspaceId(data.workspaceId);
367
+ await pollWorkspaceUntilRunning(data.workspaceId);
368
+
369
+ trackEvent('onboarding_workspace_created', {
370
+ workspaceId: data.workspaceId,
371
+ repositories,
372
+ });
373
+ } catch (err) {
374
+ setWorkspaceState('error');
375
+ setError(err instanceof Error ? err.message : 'Failed to create workspace');
376
+ } finally {
377
+ setIsCreatingWorkspace(false);
378
+ }
379
+ },
380
+ [selectedRepos, isCreatingWorkspace, workspaceState, trackEvent, buildHeaders, pollWorkspaceUntilRunning]
381
+ );
382
+
383
+ const pollForCliCompletion = useCallback(
384
+ async (statusWorkspaceId?: string): Promise<boolean> => {
385
+ cliPollingRef.current = true;
386
+ setIsPollingCli(true);
387
+
388
+ try {
389
+ const maxAttempts = 120; // 10 minutes at 5s intervals
390
+ let attempts = 0;
391
+
392
+ while (cliPollingRef.current && attempts < maxAttempts) {
393
+ const connected = await verifyProviderConnected(statusWorkspaceId);
394
+ if (connected) {
395
+ setProviderFeedback({ type: 'success', text: `${selectedProviderMeta.label} connected successfully.` });
396
+ setNextStep('create_workspace');
397
+ return true;
398
+ }
399
+
400
+ await sleep(5000);
401
+ attempts += 1;
402
+ }
403
+
404
+ return false;
405
+ } finally {
406
+ cliPollingRef.current = false;
407
+ setIsPollingCli(false);
408
+ }
409
+ },
410
+ [selectedProviderMeta.label, verifyProviderConnected]
411
+ );
412
+
81
413
  useEffect(() => {
82
414
  const init = async () => {
83
415
  try {
84
- // Check session
85
416
  const sessionRes = await fetch('/api/auth/session', { credentials: 'include' });
86
417
 
87
418
  if (sessionRes.status === 404) {
88
- // Local mode - redirect to main app
89
419
  window.location.href = '/app';
90
420
  return;
91
421
  }
92
422
 
93
- // Capture CSRF token
94
423
  const token = sessionRes.headers.get('X-CSRF-Token');
95
424
  if (token) {
96
425
  setCsrfToken(token);
@@ -103,83 +432,266 @@ function OnboardingContent() {
103
432
  return;
104
433
  }
105
434
 
106
- // Check if user already has workspaces - if so, redirect to /app
107
- const workspacesRes = await fetch('/api/workspaces', { credentials: 'include' });
108
- if (workspacesRes.ok) {
109
- const workspacesData = await workspacesRes.json();
110
- if ((workspacesData.workspaces || []).length > 0) {
111
- // User has workspaces, redirect to main app
112
- window.location.href = '/app';
113
- return;
114
- }
115
- }
435
+ // Note: we intentionally do NOT redirect to /app if workspaces exist.
436
+ // DashboardPageClient redirects here when no running workspaces, so redirecting
437
+ // back would cause an infinite loop. The onboarding page handles all states.
116
438
 
117
- // Fetch repos
118
439
  const reposRes = await fetch('/api/github-app/repos', { credentials: 'include' });
440
+ const repositories: Repository[] = [];
441
+
119
442
  if (reposRes.ok) {
120
443
  const reposData = await reposRes.json();
121
- setRepos(reposData.repositories || []);
444
+ repositories.push(...(reposData.repositories || []));
122
445
  }
123
446
 
124
- // Track page view
125
- trackEvent('onboarding_page_view', { reason });
447
+ setRepos(repositories);
448
+
449
+ let nextStepData: NextStepResponse | null = null;
450
+ try {
451
+ nextStepData = (await getOnboardingNextStep()) as NextStepResponse;
452
+ } catch {
453
+ nextStepData = null;
454
+ }
455
+
456
+ const calculatedStep =
457
+ nextStepData?.nextStep ||
458
+ (repositories.length > 0 ? 'connect_ai_provider' : 'connect_repos');
459
+
460
+ setNextStep(calculatedStep);
461
+ setSelectedRepos(resolveSelectedRepos(repositories, nextStepData));
462
+
463
+ // Initialize connected providers from API response
464
+ if (Array.isArray(nextStepData?.connectedProviders)) {
465
+ const providerMap: Record<string, ProviderId> = {
466
+ anthropic: 'anthropic',
467
+ openai: 'openai',
468
+ google: 'google',
469
+ cursor: 'cursor',
470
+ };
471
+ const initialConnected = new Set<ProviderId>();
472
+ for (const p of nextStepData.connectedProviders) {
473
+ const mapped = providerMap[p];
474
+ if (mapped) initialConnected.add(mapped);
475
+ }
476
+ setConnectedProviders(initialConnected);
477
+ }
126
478
 
479
+ trackEvent('onboarding_page_view', { reason, nextStep: calculatedStep });
127
480
  setIsLoading(false);
128
481
  } catch (err) {
129
- console.error('Onboarding init error:', err);
130
482
  setError(err instanceof Error ? err.message : 'Failed to initialize');
131
483
  setIsLoading(false);
132
484
  }
133
485
  };
134
486
 
135
487
  init();
488
+
489
+ return () => {
490
+ cliPollingRef.current = false;
491
+ workspacePollingRef.current = false;
492
+ };
493
+ }, [reason, resolveSelectedRepos, trackEvent]);
494
+
495
+ useEffect(() => {
496
+ if (
497
+ !isLoading &&
498
+ repos.length > 0 &&
499
+ selectedRepos.size > 0 &&
500
+ nextStep !== null &&
501
+ nextStep !== 'connect_ai_provider' &&
502
+ nextStep !== 'provider_connected' &&
503
+ !workspaceId &&
504
+ workspaceState === 'idle' &&
505
+ !isCreatingWorkspace
506
+ ) {
507
+ startWorkspaceCreation(Array.from(selectedRepos));
508
+ }
509
+ }, [
510
+ isLoading,
511
+ repos.length,
512
+ selectedRepos,
513
+ nextStep,
514
+ workspaceId,
515
+ workspaceState,
516
+ isCreatingWorkspace,
517
+ startWorkspaceCreation,
518
+ ]);
519
+
520
+ const handleConnectRepos = useCallback(() => {
521
+ trackEvent('onboarding_connect_repos_clicked', { reason });
522
+ window.location.href = '/connect-repos';
136
523
  }, [reason, trackEvent]);
137
524
 
138
- const handleCreateWorkspace = useCallback(async (repoFullName: string) => {
139
- setIsCreating(true);
140
- setError(null);
525
+ const handleConnectApiKey = useCallback(async () => {
526
+ if (!apiKey.trim()) {
527
+ setProviderFeedback({ type: 'error', text: 'Please enter an API key.' });
528
+ return;
529
+ }
141
530
 
142
- trackEvent('onboarding_repo_selected', { repository: repoFullName });
531
+ if (selectedRepos.size === 0) {
532
+ setProviderFeedback({ type: 'error', text: 'Select at least one repository before connecting a provider.' });
533
+ return;
534
+ }
535
+
536
+ setIsSubmittingProvider(true);
537
+ setProviderFeedback(null);
538
+ setError(null);
143
539
 
144
540
  try {
145
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
146
- if (csrfToken) {
147
- headers['X-CSRF-Token'] = csrfToken;
541
+ const providerCandidates = selectedProvider === 'openai' ? ['openai', 'codex'] : [selectedProvider];
542
+ let lastError = 'Failed to connect provider';
543
+ let connected = false;
544
+
545
+ for (const providerName of providerCandidates) {
546
+ const res = await fetch(`/api/providers/${providerName}/api-key`, {
547
+ method: 'POST',
548
+ credentials: 'include',
549
+ headers: buildHeaders(),
550
+ body: JSON.stringify({ apiKey: apiKey.trim() }),
551
+ });
552
+
553
+ if (res.ok) {
554
+ connected = true;
555
+ break;
556
+ }
557
+
558
+ const data = await res.json().catch(() => ({}));
559
+ lastError = data.error || data.message || `Failed to connect ${selectedProviderMeta.label}`;
148
560
  }
149
561
 
150
- const res = await fetch('/api/workspaces/quick', {
151
- method: 'POST',
152
- credentials: 'include',
153
- headers,
154
- body: JSON.stringify({ repositoryFullName: repoFullName }),
562
+ if (!connected) {
563
+ throw new Error(lastError);
564
+ }
565
+
566
+ setApiKey('');
567
+ setAuthMode('api_key');
568
+ setConnectedProviders(prev => new Set([...prev, selectedProvider]));
569
+ setProviderFeedback({ type: 'success', text: `${selectedProviderMeta.label} connected! Add another provider or continue to create your workspace.` });
570
+ setNextStep('provider_connected');
571
+ } catch (err) {
572
+ setProviderFeedback({
573
+ type: 'error',
574
+ text: err instanceof Error ? err.message : 'Failed to connect provider',
155
575
  });
576
+ } finally {
577
+ setIsSubmittingProvider(false);
578
+ }
579
+ }, [apiKey, selectedRepos, selectedProvider, selectedProviderMeta.label, buildHeaders]);
580
+
581
+ const handleStartCliAuth = useCallback(async () => {
582
+ if (selectedRepos.size === 0) {
583
+ setProviderFeedback({ type: 'error', text: 'Select at least one repository before starting provider auth.' });
584
+ return;
585
+ }
586
+
587
+ setIsSubmittingProvider(true);
588
+ setProviderFeedback(null);
589
+ setCliCommand(null);
590
+ setError(null);
591
+
592
+ try {
593
+ const headers = buildHeaders();
594
+ const providerCandidates = selectedProvider === 'openai' ? ['openai', 'codex'] : [selectedProvider];
595
+
596
+ let initData: { command?: string; commandWithUrl?: string; workspaceId?: string } | null = null;
597
+ let lastError = 'Failed to start CLI authentication';
598
+
599
+ for (const providerName of providerCandidates) {
600
+ const res = await fetch('/api/auth/ssh/init', {
601
+ method: 'POST',
602
+ credentials: 'include',
603
+ headers,
604
+ body: JSON.stringify({
605
+ provider: providerName,
606
+ mode: 'onboarding',
607
+ }),
608
+ });
609
+
610
+ if (res.ok) {
611
+ initData = (await res.json()) as { command?: string; commandWithUrl?: string; workspaceId?: string };
612
+ break;
613
+ }
614
+
615
+ const data = await res.json().catch(() => ({}));
616
+ lastError = data.error || data.message || 'Failed to start CLI authentication';
617
+ }
156
618
 
157
- const data = await res.json();
619
+ if (!initData) {
620
+ throw new Error(lastError);
621
+ }
158
622
 
159
- if (!res.ok) {
160
- throw new Error(data.error || 'Failed to create workspace');
623
+ const rawCommand = initData.commandWithUrl || initData.command;
624
+ if (!rawCommand) {
625
+ throw new Error('Auth broker did not return a command');
161
626
  }
162
627
 
163
- trackEvent('onboarding_workspace_created', {
164
- workspaceId: data.workspaceId,
165
- repository: repoFullName,
628
+ const commandWithNpx = rawCommand.trim().startsWith('npx ') ? rawCommand : `npx ${rawCommand}`;
629
+ setCliCommand(commandWithNpx);
630
+ setProviderFeedback({
631
+ type: 'info',
632
+ text: 'Run the command below in your terminal. This page will update automatically when auth completes.',
166
633
  });
167
634
 
168
- // Redirect to main app - it will handle provisioning state
169
- window.location.href = '/app';
635
+ const connected = await pollForCliCompletion(initData.workspaceId);
636
+ if (connected) {
637
+ setAuthMode('api_key');
638
+ setConnectedProviders(prev => new Set([...prev, selectedProvider]));
639
+ setCliCommand(null);
640
+ setNextStep('provider_connected');
641
+ } else {
642
+ setProviderFeedback({
643
+ type: 'error',
644
+ text: 'Still waiting for authentication. Complete the CLI flow, then click "Done".',
645
+ });
646
+ }
170
647
  } catch (err) {
171
- console.error('Create workspace error:', err);
172
- setError(err instanceof Error ? err.message : 'Failed to create workspace');
173
- setIsCreating(false);
648
+ setProviderFeedback({
649
+ type: 'error',
650
+ text: err instanceof Error ? err.message : 'Failed to start CLI authentication',
651
+ });
652
+ } finally {
653
+ setIsSubmittingProvider(false);
174
654
  }
175
- }, [csrfToken, trackEvent]);
655
+ }, [buildHeaders, selectedProvider, selectedRepos, pollForCliCompletion]);
176
656
 
177
- const handleConnectRepos = useCallback(() => {
178
- trackEvent('onboarding_connect_repos_clicked', { reason });
179
- window.location.href = '/connect-repos';
180
- }, [reason, trackEvent]);
657
+ const switchToCliMode = useCallback(() => {
658
+ setAuthMode('cli');
659
+ if (!cliCommand && !isSubmittingProvider && selectedRepos.size > 0) {
660
+ handleStartCliAuth();
661
+ }
662
+ }, [cliCommand, isSubmittingProvider, selectedRepos, handleStartCliAuth]);
663
+
664
+ const handleCliDone = useCallback(async () => {
665
+ setIsSubmittingProvider(true);
666
+ try {
667
+ const connected = await verifyProviderConnected();
668
+ if (!connected) {
669
+ setProviderFeedback({
670
+ type: 'error',
671
+ text: 'Provider is not connected yet. Complete the CLI authentication first.',
672
+ });
673
+ return;
674
+ }
675
+
676
+ setAuthMode('api_key');
677
+ setConnectedProviders(prev => new Set([...prev, selectedProvider]));
678
+ setCliCommand(null);
679
+ setProviderFeedback({ type: 'success', text: `${selectedProviderMeta.label} connected! Add another provider or continue to create your workspace.` });
680
+ setNextStep('provider_connected');
681
+ } finally {
682
+ setIsSubmittingProvider(false);
683
+ }
684
+ }, [selectedProviderMeta.label, selectedProvider, verifyProviderConnected]);
685
+
686
+ const handleCopyCommand = useCallback(async () => {
687
+ if (!cliCommand) {
688
+ return;
689
+ }
690
+ await navigator.clipboard.writeText(cliCommand);
691
+ setCopied(true);
692
+ setTimeout(() => setCopied(false), 2000);
693
+ }, [cliCommand]);
181
694
 
182
- // Loading state
183
695
  if (isLoading) {
184
696
  return (
185
697
  <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
@@ -188,34 +700,56 @@ function OnboardingContent() {
188
700
  <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
189
701
  <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
190
702
  </svg>
191
- <p className="mt-4 text-text-muted">Loading...</p>
703
+ <p className="mt-4 text-text-muted">Loading onboarding...</p>
192
704
  </div>
193
705
  </div>
194
706
  );
195
707
  }
196
708
 
197
- // Creating workspace state
198
- if (isCreating) {
709
+ if (workspaceState === 'provisioning' || isCreatingWorkspace) {
199
710
  return (
200
- <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
201
- <div className="text-center">
202
- <svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
203
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
204
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
205
- </svg>
206
- <p className="mt-4 text-white font-medium">Creating your workspace...</p>
207
- <p className="mt-2 text-text-muted text-sm">This may take a few minutes</p>
711
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center p-4">
712
+ <div className="w-full max-w-2xl">
713
+ <ProvisioningProgress
714
+ currentStage={provisioningStage}
715
+ isProvisioning={true}
716
+ workspaceName={Array.from(selectedRepos).join(', ')}
717
+ error={error}
718
+ />
719
+ </div>
720
+ </div>
721
+ );
722
+ }
723
+
724
+ if (workspaceState === 'running' && workspaceId) {
725
+ return (
726
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center p-4">
727
+ <div className="w-full max-w-xl bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-8 text-center">
728
+ <div className="w-16 h-16 rounded-full bg-success/20 flex items-center justify-center mx-auto mb-4">
729
+ <svg className="w-8 h-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
730
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
731
+ </svg>
732
+ </div>
733
+ <h1 className="text-2xl font-bold text-white">Workspace Ready</h1>
734
+ <p className="mt-2 text-text-muted">
735
+ Your workspace is running and ready for your first agent.
736
+ </p>
737
+ <a
738
+ href={`/app?workspace=${workspaceId}&spawn=true`}
739
+ className="mt-6 inline-flex items-center gap-2 py-3 px-6 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-xl hover:shadow-glow-cyan transition-all"
740
+ >
741
+ Spawn your first agent
742
+ </a>
208
743
  </div>
209
744
  </div>
210
745
  );
211
746
  }
212
747
 
213
- // Determine content based on reason
214
748
  const isDeletedWorkspace = reason === 'deleted';
749
+ const showProviderStep = repos.length > 0 && (nextStep === 'connect_ai_provider' || nextStep === 'provider_connected');
215
750
 
216
751
  return (
217
752
  <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
218
- {/* Background grid */}
219
753
  <div className="fixed inset-0 opacity-10 pointer-events-none">
220
754
  <div
221
755
  className="absolute inset-0"
@@ -228,7 +762,6 @@ function OnboardingContent() {
228
762
  </div>
229
763
 
230
764
  <div className="relative z-10 w-full max-w-2xl">
231
- {/* Logo and Header */}
232
765
  <div className="flex flex-col items-center mb-8">
233
766
  <LogoIcon size={56} withGlow={true} />
234
767
  <h1 className="mt-6 text-3xl font-bold text-white">
@@ -236,130 +769,279 @@ function OnboardingContent() {
236
769
  </h1>
237
770
  <p className="mt-3 text-text-muted text-center max-w-md">
238
771
  {isDeletedWorkspace
239
- ? 'Your workspace has been deleted. Create a new one to continue working with AI agents.'
240
- : 'Get started by creating your first workspace. Connect a repository and let AI agents help you build.'}
772
+ ? 'Your workspace was deleted. Reconnect and provision a new environment to continue.'
773
+ : 'Connect an AI provider, then we will automatically provision your first workspace.'}
241
774
  </p>
242
775
  </div>
243
776
 
244
- {/* Error message */}
245
777
  {error && (
246
778
  <div className="mb-6 p-4 bg-error/10 border border-error/20 rounded-xl">
247
779
  <p className="text-error text-center">{error}</p>
248
780
  </div>
249
781
  )}
250
782
 
251
- {/* Main content card */}
252
783
  <div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-8">
253
- {/* Step indicator for first-time users */}
254
- {!isDeletedWorkspace && (
784
+ {repos.length > 0 && (
255
785
  <div className="flex items-center justify-center gap-3 mb-8">
256
- <div className="flex items-center gap-2">
257
- <div className="w-8 h-8 rounded-full bg-accent-cyan flex items-center justify-center text-bg-deep font-semibold text-sm">
258
- 1
259
- </div>
260
- <span className="text-white font-medium">Select Repository</span>
261
- </div>
786
+ <StepBadge label="Repository" active={selectedRepos.size > 0} done={selectedRepos.size > 0} />
262
787
  <div className="w-12 h-px bg-border-subtle" />
263
- <div className="flex items-center gap-2">
264
- <div className="w-8 h-8 rounded-full bg-bg-tertiary border border-border-subtle flex items-center justify-center text-text-muted font-semibold text-sm">
265
- 2
266
- </div>
267
- <span className="text-text-muted">Connect AI Provider</span>
268
- </div>
788
+ <StepBadge
789
+ label="Provider"
790
+ active={showProviderStep}
791
+ done={nextStep !== 'connect_ai_provider'}
792
+ />
269
793
  <div className="w-12 h-px bg-border-subtle" />
270
- <div className="flex items-center gap-2">
271
- <div className="w-8 h-8 rounded-full bg-bg-tertiary border border-border-subtle flex items-center justify-center text-text-muted font-semibold text-sm">
272
- 3
273
- </div>
274
- <span className="text-text-muted">Start Building</span>
275
- </div>
794
+ <StepBadge label="Provision" active={false} done={false} />
276
795
  </div>
277
796
  )}
278
797
 
279
- <h2 className="text-xl font-semibold text-white mb-2">
280
- {isDeletedWorkspace ? 'Create a New Workspace' : 'Choose a Repository'}
281
- </h2>
282
- <p className="text-text-muted mb-6">
283
- {isDeletedWorkspace
284
- ? 'Select a repository to create a new workspace for your AI agents.'
285
- : 'Your workspace will be set up with this repository. You can add more repos later.'}
286
- </p>
287
-
288
- {repos.length > 0 ? (
289
- <div className="space-y-3">
290
- {repos.map((repo) => (
291
- <button
292
- key={repo.id}
293
- onClick={() => handleCreateWorkspace(repo.fullName)}
294
- disabled={isCreating}
295
- className="w-full flex items-center gap-4 p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 hover:bg-bg-hover transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
296
- >
297
- <div className="w-12 h-12 rounded-lg bg-bg-card border border-border-subtle flex items-center justify-center flex-shrink-0 group-hover:border-accent-cyan/30 transition-colors">
298
- <svg className="w-6 h-6 text-text-muted group-hover:text-accent-cyan transition-colors" fill="currentColor" viewBox="0 0 16 16">
299
- <path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" />
300
- </svg>
301
- </div>
302
- <div className="flex-1 min-w-0">
303
- <p className="text-white font-medium truncate group-hover:text-accent-cyan transition-colors">
304
- {repo.fullName}
305
- </p>
306
- <p className="text-text-muted text-sm mt-0.5">
307
- {repo.isPrivate ? 'Private repository' : 'Public repository'} · {repo.defaultBranch}
308
- </p>
309
- </div>
310
- <svg className="w-5 h-5 text-text-muted group-hover:text-accent-cyan group-hover:translate-x-1 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor">
311
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
312
- </svg>
313
- </button>
314
- ))}
315
- </div>
316
- ) : (
317
- <div className="text-center py-12 bg-bg-tertiary rounded-xl border border-border-subtle">
318
- <div className="w-16 h-16 mx-auto mb-4 bg-bg-card rounded-full flex items-center justify-center">
319
- <svg className="w-8 h-8 text-text-muted" fill="currentColor" viewBox="0 0 24 24">
320
- <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
321
- </svg>
322
- </div>
798
+ {repos.length === 0 ? (
799
+ <div className="text-center py-8 bg-bg-tertiary rounded-xl border border-border-subtle">
323
800
  <h3 className="text-lg font-semibold text-white mb-2">No Repositories Connected</h3>
324
801
  <p className="text-text-muted mb-6 max-w-sm mx-auto">
325
- Connect your GitHub repositories to create a workspace and start working with AI agents.
802
+ Connect your GitHub repositories to continue onboarding.
326
803
  </p>
327
804
  <button
328
805
  onClick={handleConnectRepos}
329
806
  className="inline-flex items-center gap-2 py-3 px-6 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-xl hover:shadow-glow-cyan transition-all"
330
807
  >
331
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
332
- <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
333
- </svg>
334
808
  Connect GitHub
335
809
  </button>
336
810
  </div>
811
+ ) : showProviderStep ? (
812
+ <div className="space-y-6">
813
+ <div>
814
+ <label className="block text-sm text-text-muted mb-2">Repositories</label>
815
+ <div className="max-h-48 overflow-y-auto space-y-2 p-3 bg-bg-tertiary border border-border-subtle rounded-xl">
816
+ {repos.map((repo) => (
817
+ <label
818
+ key={repo.id}
819
+ className="flex items-center gap-3 p-2 rounded-lg hover:bg-bg-card cursor-pointer transition-colors"
820
+ >
821
+ <input
822
+ type="checkbox"
823
+ checked={selectedRepos.has(repo.fullName)}
824
+ onChange={() => {
825
+ setSelectedRepos(prev => {
826
+ const next = new Set(prev);
827
+ if (next.has(repo.fullName)) {
828
+ next.delete(repo.fullName);
829
+ } else {
830
+ next.add(repo.fullName);
831
+ }
832
+ return next;
833
+ });
834
+ }}
835
+ className="w-4 h-4 rounded border-border-subtle text-accent-cyan focus:ring-accent-cyan/50 bg-bg-deep"
836
+ />
837
+ <span className="text-white text-sm">{repo.fullName}</span>
838
+ {repo.isPrivate && (
839
+ <span className="text-xs text-text-muted bg-bg-deep px-1.5 py-0.5 rounded">private</span>
840
+ )}
841
+ </label>
842
+ ))}
843
+ </div>
844
+ <p className="text-xs text-text-muted mt-1">{selectedRepos.size} selected</p>
845
+ </div>
846
+
847
+ <div>
848
+ <p className="text-sm text-text-muted mb-3">Select AI provider</p>
849
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
850
+ {PROVIDERS.map((provider) => {
851
+ const isConnected = connectedProviders.has(provider.id);
852
+ return (
853
+ <button
854
+ key={provider.id}
855
+ type="button"
856
+ onClick={() => {
857
+ setSelectedProvider(provider.id);
858
+ // Reset auth state so user can connect this provider
859
+ setCliCommand(null);
860
+ setApiKey('');
861
+ setProviderFeedback(null);
862
+ if (authMode !== 'api_key') setAuthMode('api_key');
863
+ }}
864
+ className={`p-3 rounded-xl border text-left transition-colors relative ${
865
+ isConnected
866
+ ? 'border-success/50 bg-success/10'
867
+ : selectedProvider === provider.id
868
+ ? 'border-accent-cyan bg-accent-cyan/10'
869
+ : 'border-border-subtle bg-bg-tertiary hover:border-accent-cyan/40'
870
+ }`}
871
+ >
872
+ <div className="flex items-center justify-between">
873
+ <p className="text-white font-medium">{provider.label}</p>
874
+ {isConnected && (
875
+ <span className="flex items-center gap-1 text-xs text-success font-medium">
876
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
877
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
878
+ </svg>
879
+ Connected
880
+ </span>
881
+ )}
882
+ </div>
883
+ <p className="text-xs text-text-muted mt-1">{provider.description}</p>
884
+ </button>
885
+ );
886
+ })}
887
+ </div>
888
+ </div>
889
+
890
+ {/* Show "Continue" button when at least one provider is connected */}
891
+ {connectedProviders.size > 0 && nextStep === 'provider_connected' && (
892
+ <div className="flex items-center justify-between p-4 bg-success/10 border border-success/30 rounded-xl">
893
+ <p className="text-sm text-success">
894
+ {connectedProviders.size} provider{connectedProviders.size > 1 ? 's' : ''} connected.
895
+ {' '}Add more or continue to create your workspace.
896
+ </p>
897
+ <button
898
+ type="button"
899
+ onClick={() => startWorkspaceCreation()}
900
+ className="px-5 py-2.5 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-xl hover:shadow-glow-cyan transition-all text-sm"
901
+ >
902
+ Create Workspace
903
+ </button>
904
+ </div>
905
+ )}
906
+
907
+ {!connectedProviders.has(selectedProvider) && (
908
+ <div className="flex gap-2 p-1 bg-bg-tertiary rounded-xl border border-border-subtle">
909
+ <button
910
+ type="button"
911
+ onClick={() => setAuthMode('api_key')}
912
+ className={`flex-1 py-2.5 px-4 text-sm rounded-lg transition-colors ${
913
+ authMode === 'api_key' ? 'bg-accent-cyan text-bg-deep font-semibold' : 'text-text-muted hover:text-white'
914
+ }`}
915
+ >
916
+ API Key Input
917
+ </button>
918
+ <button
919
+ type="button"
920
+ onClick={switchToCliMode}
921
+ className={`flex-1 py-2.5 px-4 text-sm rounded-lg transition-colors ${
922
+ authMode === 'cli' ? 'bg-accent-cyan text-bg-deep font-semibold' : 'text-text-muted hover:text-white'
923
+ }`}
924
+ >
925
+ Authenticate via CLI
926
+ </button>
927
+ </div>
928
+ )}
929
+
930
+ {connectedProviders.has(selectedProvider) ? null : authMode === 'api_key' ? (
931
+ <div className="space-y-3">
932
+ <label className="block text-sm text-text-muted">{selectedProviderMeta.label} API Key</label>
933
+ <div className="flex gap-3">
934
+ <input
935
+ type="password"
936
+ value={apiKey}
937
+ onChange={(event) => setApiKey(event.target.value)}
938
+ placeholder="Paste API key"
939
+ className="flex-1 px-4 py-3 bg-bg-tertiary border border-border-subtle rounded-xl text-white placeholder:text-text-muted focus:outline-none focus:border-accent-cyan/50"
940
+ />
941
+ <button
942
+ type="button"
943
+ onClick={handleConnectApiKey}
944
+ disabled={isSubmittingProvider || !apiKey.trim()}
945
+ className="px-5 py-3 bg-accent-cyan text-bg-deep font-semibold rounded-xl hover:bg-accent-cyan/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
946
+ >
947
+ {isSubmittingProvider ? 'Connecting...' : 'Connect'}
948
+ </button>
949
+ </div>
950
+ </div>
951
+ ) : (
952
+ <div className="space-y-3">
953
+ {cliCommand ? (
954
+ <div className="p-4 bg-bg-tertiary border border-border-subtle rounded-xl space-y-3">
955
+ <p className="text-sm text-text-muted">Run this command in your terminal:</p>
956
+ <code className="block px-3 py-2 bg-bg-deep rounded-lg text-sm text-white overflow-x-auto">
957
+ {cliCommand}
958
+ </code>
959
+ <div className="flex gap-2">
960
+ <button
961
+ type="button"
962
+ onClick={handleCopyCommand}
963
+ className={`px-3 py-2 border rounded-lg text-xs transition-colors ${
964
+ copied
965
+ ? 'bg-success/20 border-success/50 text-success'
966
+ : 'bg-bg-card border-border-subtle text-text-muted hover:text-white hover:border-accent-cyan/50'
967
+ }`}
968
+ >
969
+ {copied ? 'Copied!' : 'Copy command'}
970
+ </button>
971
+ <button
972
+ type="button"
973
+ onClick={handleCliDone}
974
+ disabled={isSubmittingProvider}
975
+ className="px-3 py-2 bg-accent-cyan text-bg-deep rounded-lg text-xs font-semibold hover:bg-accent-cyan/90 disabled:opacity-50 transition-colors"
976
+ >
977
+ Done
978
+ </button>
979
+ </div>
980
+ </div>
981
+ ) : (
982
+ <div className="flex items-center gap-3 p-4 bg-bg-tertiary border border-border-subtle rounded-xl">
983
+ <svg className="w-5 h-5 text-accent-cyan animate-spin" fill="none" viewBox="0 0 24 24">
984
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
985
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
986
+ </svg>
987
+ <span className="text-sm text-text-muted">Fetching CLI command...</span>
988
+ </div>
989
+ )}
990
+
991
+ {isPollingCli && (
992
+ <div className="flex items-center gap-2 text-sm text-success p-3 bg-success/10 border border-success/30 rounded-lg">
993
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
994
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
995
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
996
+ </svg>
997
+ <span>Waiting for authentication to complete...</span>
998
+ </div>
999
+ )}
1000
+ </div>
1001
+ )}
1002
+
1003
+ {providerFeedback && (
1004
+ <div
1005
+ className={`p-3 rounded-lg text-sm ${
1006
+ providerFeedback.type === 'success'
1007
+ ? 'bg-success/10 border border-success/30 text-success'
1008
+ : providerFeedback.type === 'error'
1009
+ ? 'bg-error/10 border border-error/30 text-error'
1010
+ : 'bg-accent-cyan/10 border border-accent-cyan/30 text-accent-cyan'
1011
+ }`}
1012
+ >
1013
+ {providerFeedback.text}
1014
+ </div>
1015
+ )}
1016
+ </div>
1017
+ ) : (
1018
+ <div className="text-center py-8">
1019
+ <svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
1020
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
1021
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
1022
+ </svg>
1023
+ <p className="mt-4 text-white font-medium">Preparing workspace provisioning...</p>
1024
+ <p className="mt-2 text-text-muted text-sm">You will be redirected automatically when ready.</p>
1025
+ </div>
337
1026
  )}
338
1027
  </div>
339
1028
 
340
- {/* Footer navigation */}
341
1029
  <div className="mt-8 flex justify-center gap-6 text-sm">
342
1030
  {repos.length > 0 && (
343
- <button
344
- onClick={handleConnectRepos}
345
- className="text-text-muted hover:text-white transition-colors"
346
- >
1031
+ <button onClick={handleConnectRepos} className="text-text-muted hover:text-white transition-colors">
347
1032
  Connect More Repositories
348
1033
  </button>
349
1034
  )}
350
- <a
351
- href="/app"
352
- className="text-text-muted hover:text-white transition-colors"
353
- >
1035
+ <a href="/app" className="text-text-muted hover:text-white transition-colors">
354
1036
  Back to Dashboard
355
1037
  </a>
356
1038
  <button
357
1039
  onClick={async () => {
358
- const headers: Record<string, string> = {};
359
- if (csrfToken) {
360
- headers['X-CSRF-Token'] = csrfToken;
361
- }
362
- await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', headers });
1040
+ await fetch('/api/auth/logout', {
1041
+ method: 'POST',
1042
+ credentials: 'include',
1043
+ headers: buildHeaders(false),
1044
+ });
363
1045
  window.location.href = '/login';
364
1046
  }}
365
1047
  className="text-text-muted hover:text-white transition-colors"
@@ -372,7 +1054,25 @@ function OnboardingContent() {
372
1054
  );
373
1055
  }
374
1056
 
375
- // Wrap in Suspense for useSearchParams
1057
+ function StepBadge({ label, active, done }: { label: string; active: boolean; done: boolean }) {
1058
+ return (
1059
+ <div className="flex items-center gap-2">
1060
+ <div
1061
+ className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
1062
+ done
1063
+ ? 'bg-success text-bg-deep'
1064
+ : active
1065
+ ? 'bg-accent-cyan text-bg-deep'
1066
+ : 'bg-bg-tertiary border border-border-subtle text-text-muted'
1067
+ }`}
1068
+ >
1069
+ {done ? '✓' : label[0]}
1070
+ </div>
1071
+ <span className={`${active || done ? 'text-white' : 'text-text-muted'} text-sm`}>{label}</span>
1072
+ </div>
1073
+ );
1074
+ }
1075
+
376
1076
  export default function OnboardingPage() {
377
1077
  return (
378
1078
  <Suspense