@agent-relay/dashboard 2.0.80 → 2.0.82

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 (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Dashboard V2 - Main App Page (Client Component)
3
+ *
4
+ * In cloud mode: Shows workspace selection and connects to selected workspace's dashboard.
5
+ * In local mode: Connects to local daemon WebSocket.
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import React, { useState, useEffect, useCallback } from 'react';
11
+ import { App } from '../../../components/App';
12
+ import { CloudSessionProvider } from '../../../components/CloudSessionProvider';
13
+ import { LogoIcon } from '../../../components/Logo';
14
+ import { setActiveWorkspaceId } from '../../../lib/api';
15
+ import { ProvisioningProgress } from '../../../components/ProvisioningProgress';
16
+ import { ProviderConnectionList, type ProviderInfo } from '../../../components/ProviderConnectionList';
17
+
18
+ interface Workspace {
19
+ id: string;
20
+ name: string;
21
+ status: 'provisioning' | 'running' | 'stopped' | 'error';
22
+ publicUrl?: string;
23
+ providers?: string[];
24
+ repositories?: string[];
25
+ createdAt: string;
26
+ }
27
+
28
+ interface Repository {
29
+ id: string;
30
+ fullName: string;
31
+ isPrivate: boolean;
32
+ defaultBranch: string;
33
+ syncStatus: string;
34
+ hasNangoConnection: boolean;
35
+ }
36
+
37
+ type PageState = 'loading' | 'local' | 'select-workspace' | 'no-workspaces' | 'provisioning' | 'connect-provider' | 'connecting' | 'connected' | 'error' | 'create-workspace';
38
+
39
+ interface ProvisioningInfo {
40
+ workspaceId: string;
41
+ workspaceName: string;
42
+ stage: string | null;
43
+ startedAt: number;
44
+ }
45
+
46
+ // Available AI providers
47
+ const AI_PROVIDERS: ProviderInfo[] = [
48
+ { id: 'anthropic', name: 'Anthropic', displayName: 'Claude', color: '#D97757', cliCommand: 'claude', requiresUrlCopy: true },
49
+ { id: 'codex', name: 'OpenAI', displayName: 'Codex', color: '#10A37F', cliCommand: 'codex login', requiresUrlCopy: true },
50
+ { id: 'google', name: 'Google', displayName: 'Gemini', color: '#4285F4', cliCommand: 'gemini' },
51
+ { id: 'opencode', name: 'OpenCode', displayName: 'OpenCode', color: '#00D4AA', cliCommand: 'opencode' },
52
+ { id: 'droid', name: 'Factory', displayName: 'Droid', color: '#6366F1', cliCommand: 'droid' },
53
+ { id: 'cursor', name: 'Cursor', displayName: 'Cursor', color: '#7C3AED', cliCommand: 'agent', requiresUrlCopy: true },
54
+ ];
55
+
56
+ // Force cloud mode via env var - prevents silent fallback to local mode
57
+ const FORCE_CLOUD_MODE = process.env.NEXT_PUBLIC_FORCE_CLOUD_MODE === 'true';
58
+
59
+ export default function DashboardPageClient() {
60
+ const [state, setState] = useState<PageState>('loading');
61
+ const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
62
+ const [repos, setRepos] = useState<Repository[]>([]);
63
+ const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(null);
64
+ const [wsUrl, setWsUrl] = useState<string | undefined>(undefined);
65
+ const [error, setError] = useState<string | null>(null);
66
+ // Track cloud mode for potential future use
67
+ const [_isCloudMode, setIsCloudMode] = useState(FORCE_CLOUD_MODE);
68
+ const [csrfToken, setCsrfToken] = useState<string | null>(null);
69
+ const [provisioningInfo, setProvisioningInfo] = useState<ProvisioningInfo | null>(null);
70
+ const [connectedProviders, setConnectedProviders] = useState<string[]>([]);
71
+
72
+ // Check if we're in cloud mode and fetch data
73
+ useEffect(() => {
74
+ const init = async () => {
75
+ try {
76
+ // Check session to determine if we're in cloud mode
77
+ const sessionRes = await fetch('/api/auth/session', { credentials: 'include' });
78
+
79
+ // If session endpoint doesn't exist (404), we're in local mode
80
+ if (sessionRes.status === 404) {
81
+ if (FORCE_CLOUD_MODE) {
82
+ throw new Error('Cloud mode enforced but session endpoint returned 404. Is the cloud server running?');
83
+ }
84
+ setIsCloudMode(false);
85
+ setState('local');
86
+ return;
87
+ }
88
+
89
+ // Capture CSRF token from response header
90
+ const token = sessionRes.headers.get('X-CSRF-Token');
91
+ if (token) {
92
+ setCsrfToken(token);
93
+ }
94
+
95
+ const session = await sessionRes.json();
96
+
97
+ if (!session.authenticated) {
98
+ // Cloud mode but not authenticated - redirect to login
99
+ window.location.href = '/login';
100
+ return;
101
+ }
102
+
103
+ // Cloud mode - fetch workspaces and repos
104
+ setIsCloudMode(true);
105
+
106
+ // Track which providers are already connected
107
+ // Map backend IDs to frontend IDs for consistency
108
+ const BACKEND_TO_FRONTEND_MAP: Record<string, string> = {
109
+ openai: 'codex', // Backend stores 'openai', frontend uses 'codex'
110
+ };
111
+ if (session.connectedProviders) {
112
+ const providers: string[] = [];
113
+ session.connectedProviders.forEach((p: { provider: string }) => {
114
+ providers.push(p.provider);
115
+ // Also add the frontend ID if there's a mapping
116
+ const frontendId = BACKEND_TO_FRONTEND_MAP[p.provider];
117
+ if (frontendId) {
118
+ providers.push(frontendId);
119
+ }
120
+ });
121
+ setConnectedProviders(providers);
122
+ }
123
+
124
+ const [workspacesRes, reposRes] = await Promise.all([
125
+ // Use /accessible to include workspaces user can access via GitHub repo permissions
126
+ fetch('/api/workspaces/accessible', { credentials: 'include' }),
127
+ fetch('/api/github-app/repos', { credentials: 'include' }),
128
+ ]);
129
+
130
+ if (!workspacesRes.ok) {
131
+ if (workspacesRes.status === 401) {
132
+ window.location.href = '/login';
133
+ return;
134
+ }
135
+ throw new Error('Failed to fetch workspaces');
136
+ }
137
+
138
+ const workspacesData = await workspacesRes.json();
139
+ const reposData = reposRes.ok ? await reposRes.json() : { repositories: [] };
140
+
141
+ setWorkspaces(workspacesData.workspaces || []);
142
+ setRepos(reposData.repositories || []);
143
+
144
+ // Determine next state based on workspace availability
145
+ const runningWorkspaces = (workspacesData.workspaces || []).filter(
146
+ (w: Workspace) => w.status === 'running' && w.publicUrl
147
+ );
148
+
149
+ // Check if user explicitly wants to see workspace picker (from "Add Workspace" button)
150
+ const urlParams = typeof window !== 'undefined'
151
+ ? new URLSearchParams(window.location.search)
152
+ : null;
153
+ const forceShowPicker = urlParams?.get('select') === 'true';
154
+
155
+ // If user explicitly requested the picker, show it and clean up URL
156
+ if (forceShowPicker) {
157
+ // Remove query param from URL without reload
158
+ window.history.replaceState({}, '', '/app');
159
+ setState('select-workspace');
160
+ return;
161
+ }
162
+
163
+ // Check for previously connected workspace (stored in localStorage)
164
+ // This enables seamless reconnection on page reload
165
+ const savedWorkspaceId = typeof window !== 'undefined'
166
+ ? localStorage.getItem('agentrelay_workspace_id')
167
+ : null;
168
+
169
+ if (savedWorkspaceId) {
170
+ const savedWorkspace = runningWorkspaces.find((w: Workspace) => w.id === savedWorkspaceId);
171
+ if (savedWorkspace) {
172
+ // Auto-reconnect to previously selected workspace
173
+ connectToWorkspace(savedWorkspace);
174
+ return;
175
+ }
176
+ }
177
+
178
+ if (runningWorkspaces.length === 1) {
179
+ // Auto-connect to the only running workspace
180
+ connectToWorkspace(runningWorkspaces[0]);
181
+ } else if (runningWorkspaces.length > 1) {
182
+ setState('select-workspace');
183
+ } else if ((workspacesData.workspaces || []).length > 0) {
184
+ // Has workspaces but none running
185
+ setState('select-workspace');
186
+ } else if ((reposData.repositories || []).length > 0) {
187
+ // Has repos but no workspaces - show create workspace
188
+ setState('no-workspaces');
189
+ } else {
190
+ // No repos, no workspaces - redirect to connect repos
191
+ window.location.href = '/connect-repos';
192
+ }
193
+ } catch (err) {
194
+ // If session check fails with network error, assume local mode (unless forced cloud)
195
+ if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
196
+ if (FORCE_CLOUD_MODE) {
197
+ console.error('Cloud mode enforced but network request failed:', err);
198
+ setError('Cloud mode enforced but failed to connect to server. Is the cloud server running?');
199
+ setState('error');
200
+ return;
201
+ }
202
+ setIsCloudMode(false);
203
+ setState('local');
204
+ return;
205
+ }
206
+ console.error('Init error:', err);
207
+ setError(err instanceof Error ? err.message : 'Failed to initialize');
208
+ setState('error');
209
+ }
210
+ };
211
+
212
+ init();
213
+ }, []);
214
+
215
+ const connectToWorkspace = useCallback((workspace: Workspace) => {
216
+ if (!workspace.publicUrl) {
217
+ setError('Workspace has no public URL');
218
+ setState('error');
219
+ return;
220
+ }
221
+
222
+ setSelectedWorkspace(workspace);
223
+ setState('connecting');
224
+
225
+ // Set the active workspace ID for API proxying
226
+ setActiveWorkspaceId(workspace.id);
227
+
228
+ // Derive WebSocket URL from public URL
229
+ // e.g., https://workspace-abc.agentrelay.dev -> wss://workspace-abc.agentrelay.dev/ws
230
+ const url = new URL(workspace.publicUrl);
231
+ const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
232
+ const derivedWsUrl = `${wsProtocol}//${url.host}/ws`;
233
+
234
+ setWsUrl(derivedWsUrl);
235
+ setState('connected');
236
+ }, []);
237
+
238
+ const handleCreateWorkspace = useCallback(async (repoFullName: string) => {
239
+ setError(null);
240
+
241
+ try {
242
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
243
+ if (csrfToken) {
244
+ headers['X-CSRF-Token'] = csrfToken;
245
+ }
246
+
247
+ const res = await fetch('/api/workspaces/quick', {
248
+ method: 'POST',
249
+ credentials: 'include',
250
+ headers,
251
+ body: JSON.stringify({ repositoryFullName: repoFullName }),
252
+ });
253
+
254
+ const data = await res.json();
255
+
256
+ if (!res.ok) {
257
+ throw new Error(data.error || 'Failed to create workspace');
258
+ }
259
+
260
+ // Set provisioning state with workspace info
261
+ const startedAt = Date.now();
262
+ setProvisioningInfo({
263
+ workspaceId: data.workspaceId,
264
+ workspaceName: repoFullName.split('/')[1] || repoFullName,
265
+ stage: null,
266
+ startedAt,
267
+ });
268
+ setState('provisioning');
269
+
270
+ // Poll for workspace to be ready
271
+ // Cloud deployments (Fly.io) can take 3-5 minutes for cold starts
272
+ const pollForReady = async (workspaceId: string) => {
273
+ const maxAttempts = 150; // 5 minutes with 2s interval
274
+ const pollIntervalMs = 2000;
275
+ let attempts = 0;
276
+
277
+ while (attempts < maxAttempts) {
278
+ const statusRes = await fetch(`/api/workspaces/${workspaceId}/status`, {
279
+ credentials: 'include',
280
+ });
281
+ const statusData = await statusRes.json();
282
+
283
+ // Update provisioning stage if available
284
+ if (statusData.provisioning?.stage) {
285
+ setProvisioningInfo(prev => prev ? {
286
+ ...prev,
287
+ stage: statusData.provisioning.stage,
288
+ } : null);
289
+ }
290
+
291
+ if (statusData.status === 'running') {
292
+ // Fetch updated workspace info
293
+ const wsRes = await fetch(`/api/workspaces/${workspaceId}`, {
294
+ credentials: 'include',
295
+ });
296
+ const wsData = await wsRes.json();
297
+ if (wsData.publicUrl) {
298
+ // Clear provisioning info and show provider connection screen
299
+ setProvisioningInfo(null);
300
+ setSelectedWorkspace(wsData);
301
+ setState('connect-provider');
302
+ return;
303
+ }
304
+ } else if (statusData.status === 'error') {
305
+ const errorMsg = statusData.errorMessage || 'Workspace provisioning failed';
306
+ throw new Error(errorMsg);
307
+ }
308
+
309
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
310
+ attempts++;
311
+
312
+ // Log progress every 30 seconds
313
+ if (attempts % 15 === 0) {
314
+ console.log(`[workspace] Still provisioning... (${Math.floor(attempts * pollIntervalMs / 1000)}s elapsed)`);
315
+ }
316
+ }
317
+
318
+ throw new Error('Workspace provisioning timed out after 5 minutes. Please try again or contact support.');
319
+ };
320
+
321
+ await pollForReady(data.workspaceId);
322
+ } catch (err) {
323
+ console.error('Create workspace error:', err);
324
+ setProvisioningInfo(null);
325
+ setError(err instanceof Error ? err.message : 'Failed to create workspace');
326
+ setState('no-workspaces');
327
+ }
328
+ }, [connectToWorkspace, csrfToken]);
329
+
330
+ // Handle provider connection success
331
+ const handleProviderConnected = useCallback((providerId: string) => {
332
+ setConnectedProviders(prev => [...new Set([...prev, providerId])]);
333
+ }, []);
334
+
335
+ // Skip provider connection and continue to workspace
336
+ const handleSkipProvider = useCallback(() => {
337
+ if (selectedWorkspace) {
338
+ connectToWorkspace(selectedWorkspace);
339
+ }
340
+ }, [selectedWorkspace, connectToWorkspace]);
341
+
342
+ const handleStartWorkspace = useCallback(async (workspace: Workspace) => {
343
+ setState('loading');
344
+ setError(null);
345
+
346
+ try {
347
+ const headers: Record<string, string> = {};
348
+ if (csrfToken) {
349
+ headers['X-CSRF-Token'] = csrfToken;
350
+ }
351
+
352
+ const res = await fetch(`/api/workspaces/${workspace.id}/restart`, {
353
+ method: 'POST',
354
+ credentials: 'include',
355
+ headers,
356
+ });
357
+
358
+ if (!res.ok) {
359
+ const data = await res.json();
360
+ throw new Error(data.error || 'Failed to start workspace');
361
+ }
362
+
363
+ // Poll for workspace to be ready
364
+ const maxAttempts = 60;
365
+ let attempts = 0;
366
+
367
+ while (attempts < maxAttempts) {
368
+ const statusRes = await fetch(`/api/workspaces/${workspace.id}/status`, {
369
+ credentials: 'include',
370
+ });
371
+ const statusData = await statusRes.json();
372
+
373
+ if (statusData.status === 'running') {
374
+ const wsRes = await fetch(`/api/workspaces/${workspace.id}`, {
375
+ credentials: 'include',
376
+ });
377
+ const wsData = await wsRes.json();
378
+ if (wsData.publicUrl) {
379
+ connectToWorkspace({ ...workspace, ...wsData });
380
+ return;
381
+ }
382
+ }
383
+
384
+ await new Promise(resolve => setTimeout(resolve, 2000));
385
+ attempts++;
386
+ }
387
+
388
+ throw new Error('Workspace start timed out');
389
+ } catch (err) {
390
+ console.error('Start workspace error:', err);
391
+ setError(err instanceof Error ? err.message : 'Failed to start workspace');
392
+ setState('select-workspace');
393
+ }
394
+ }, [connectToWorkspace, csrfToken]);
395
+
396
+ // Loading state
397
+ if (state === 'loading') {
398
+ return (
399
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
400
+ <div className="text-center">
401
+ <svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
402
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
403
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
404
+ </svg>
405
+ <p className="mt-4 text-text-muted">Loading...</p>
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ // Local mode - just render the App component
412
+ if (state === 'local') {
413
+ return <App />;
414
+ }
415
+
416
+ // Connected to workspace - render App with workspace's WebSocket
417
+ // Wrap in CloudSessionProvider so App has access to cloud session context
418
+ if (state === 'connected' && wsUrl) {
419
+ return (
420
+ <CloudSessionProvider cloudMode={true}>
421
+ <App wsUrl={wsUrl} />
422
+ </CloudSessionProvider>
423
+ );
424
+ }
425
+
426
+ // Connecting state
427
+ if (state === 'connecting') {
428
+ return (
429
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
430
+ <div className="text-center">
431
+ <svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
432
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
433
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
434
+ </svg>
435
+ <p className="mt-4 text-white font-medium">Connecting to {selectedWorkspace?.name}...</p>
436
+ <p className="mt-2 text-text-muted text-sm">{selectedWorkspace?.publicUrl}</p>
437
+ </div>
438
+ </div>
439
+ );
440
+ }
441
+
442
+ // Provisioning state - show progress UI
443
+ if (state === 'provisioning' && provisioningInfo) {
444
+ return (
445
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center">
446
+ <div className="w-full max-w-xl">
447
+ <ProvisioningProgress
448
+ isProvisioning={true}
449
+ currentStage={provisioningInfo.stage}
450
+ workspaceName={provisioningInfo.workspaceName}
451
+ error={error}
452
+ onCancel={() => {
453
+ setProvisioningInfo(null);
454
+ setState('no-workspaces');
455
+ }}
456
+ />
457
+ </div>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ // Error state
463
+ if (state === 'error') {
464
+ return (
465
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex items-center justify-center p-4">
466
+ <div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-8 max-w-md w-full text-center">
467
+ <div className="w-16 h-16 mx-auto mb-4 bg-error/20 rounded-full flex items-center justify-center">
468
+ <svg className="w-8 h-8 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
469
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
470
+ </svg>
471
+ </div>
472
+ <h2 className="text-xl font-semibold text-white mb-2">Something went wrong</h2>
473
+ <p className="text-text-muted mb-6">{error}</p>
474
+ <button
475
+ onClick={() => window.location.reload()}
476
+ className="w-full py-3 px-4 bg-bg-tertiary border border-border-subtle rounded-xl text-white font-medium hover:bg-bg-hover transition-colors"
477
+ >
478
+ Try Again
479
+ </button>
480
+ </div>
481
+ </div>
482
+ );
483
+ }
484
+
485
+ // Connect provider state - show after workspace is ready
486
+ if (state === 'connect-provider' && selectedWorkspace) {
487
+ return (
488
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
489
+ {/* Background grid */}
490
+ <div className="fixed inset-0 opacity-10 pointer-events-none">
491
+ <div
492
+ className="absolute inset-0"
493
+ style={{
494
+ backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
495
+ linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
496
+ backgroundSize: '50px 50px',
497
+ }}
498
+ />
499
+ </div>
500
+
501
+ <div className="relative z-10 w-full max-w-xl">
502
+ {/* Logo */}
503
+ <div className="flex flex-col items-center mb-8">
504
+ <LogoIcon size={48} withGlow={true} />
505
+ <h1 className="mt-4 text-2xl font-bold text-white">Connect AI Provider</h1>
506
+ <p className="mt-2 text-text-muted text-center">
507
+ Your workspace <span className="text-white">{selectedWorkspace.name}</span> is ready!
508
+ <br />Connect an AI provider to start using agents.
509
+ </p>
510
+ </div>
511
+
512
+ {/* Shared provider connection component */}
513
+ <ProviderConnectionList
514
+ providers={AI_PROVIDERS}
515
+ connectedProviders={connectedProviders}
516
+ workspaceId={selectedWorkspace.id}
517
+ csrfToken={csrfToken || undefined}
518
+ onProviderConnected={handleProviderConnected}
519
+ onContinue={handleSkipProvider}
520
+ showDetailedInfo={true}
521
+ />
522
+ </div>
523
+ </div>
524
+ );
525
+ }
526
+
527
+ // Create workspace state - show repo selection
528
+ if (state === 'create-workspace') {
529
+ // Filter out repos that already have workspaces
530
+ // Workspace names are like "Workspace for Owner/repo" or just the repo fullName
531
+ const workspaceRepoFullNames = new Set(
532
+ workspaces.flatMap(w => {
533
+ const names: string[] = [];
534
+ // Check repositories array first
535
+ if (w.repositories && w.repositories.length > 0) {
536
+ w.repositories.forEach(r => names.push(r.toLowerCase()));
537
+ }
538
+ // Also extract from workspace name (format: "Workspace for Owner/repo" or "Owner/repo")
539
+ const match = w.name.match(/(?:Workspace for\s+)?(.+\/.+)/i);
540
+ if (match) {
541
+ names.push(match[1].toLowerCase());
542
+ }
543
+ return names;
544
+ })
545
+ );
546
+
547
+ const availableRepos = repos.filter(repo => {
548
+ return !workspaceRepoFullNames.has(repo.fullName.toLowerCase());
549
+ });
550
+
551
+ return (
552
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
553
+ {/* Background grid */}
554
+ <div className="fixed inset-0 opacity-10 pointer-events-none">
555
+ <div
556
+ className="absolute inset-0"
557
+ style={{
558
+ backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
559
+ linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
560
+ backgroundSize: '50px 50px',
561
+ }}
562
+ />
563
+ </div>
564
+
565
+ <div className="relative z-10 w-full max-w-2xl">
566
+ {/* Logo */}
567
+ <div className="flex flex-col items-center mb-8">
568
+ <LogoIcon size={48} withGlow={true} />
569
+ <h1 className="mt-4 text-2xl font-bold text-white">Create Workspace</h1>
570
+ <p className="mt-2 text-text-muted">
571
+ Select a repository to create a workspace
572
+ </p>
573
+ </div>
574
+
575
+ {error && (
576
+ <div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-xl">
577
+ <p className="text-error">{error}</p>
578
+ </div>
579
+ )}
580
+
581
+ <div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
582
+ {/* Back button */}
583
+ <button
584
+ onClick={() => setState('select-workspace')}
585
+ className="mb-4 flex items-center gap-2 text-text-muted hover:text-white transition-colors text-sm"
586
+ >
587
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
588
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
589
+ </svg>
590
+ Back to workspaces
591
+ </button>
592
+
593
+ <h2 className="text-lg font-semibold text-white mb-4">Available Repositories</h2>
594
+ <p className="text-text-muted mb-6 text-sm">
595
+ These are repositories the GitHub App has access to that don&apos;t have a workspace yet.
596
+ </p>
597
+
598
+ {availableRepos.length > 0 ? (
599
+ <div className="space-y-3">
600
+ {availableRepos.map((repo) => (
601
+ <button
602
+ key={repo.id}
603
+ onClick={() => handleCreateWorkspace(repo.fullName)}
604
+ className="w-full flex items-center gap-3 p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors text-left"
605
+ >
606
+ <svg className="w-5 h-5 text-text-muted flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
607
+ <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" />
608
+ </svg>
609
+ <div className="flex-1 min-w-0">
610
+ <p className="text-white font-medium truncate">{repo.fullName}</p>
611
+ <p className="text-text-muted text-sm">{repo.isPrivate ? 'Private' : 'Public'}</p>
612
+ </div>
613
+ <svg className="w-5 h-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
614
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
615
+ </svg>
616
+ </button>
617
+ ))}
618
+ </div>
619
+ ) : (
620
+ <div className="text-center py-8">
621
+ <p className="text-text-muted mb-4">All connected repositories already have workspaces.</p>
622
+ <a
623
+ href="/connect-repos"
624
+ 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"
625
+ >
626
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
627
+ <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" />
628
+ </svg>
629
+ Connect More Repositories
630
+ </a>
631
+ </div>
632
+ )}
633
+ </div>
634
+ </div>
635
+ </div>
636
+ );
637
+ }
638
+
639
+ // Workspace selection / no workspaces UI
640
+ return (
641
+ <div className="min-h-screen bg-gradient-to-br from-[#0a0a0f] via-[#0d1117] to-[#0a0a0f] flex flex-col items-center justify-center p-4">
642
+ {/* Background grid */}
643
+ <div className="fixed inset-0 opacity-10 pointer-events-none">
644
+ <div
645
+ className="absolute inset-0"
646
+ style={{
647
+ backgroundImage: `linear-gradient(rgba(0, 217, 255, 0.1) 1px, transparent 1px),
648
+ linear-gradient(90deg, rgba(0, 217, 255, 0.1) 1px, transparent 1px)`,
649
+ backgroundSize: '50px 50px',
650
+ }}
651
+ />
652
+ </div>
653
+
654
+ <div className="relative z-10 w-full max-w-2xl">
655
+ {/* Logo */}
656
+ <div className="flex flex-col items-center mb-8">
657
+ <LogoIcon size={48} withGlow={true} />
658
+ <h1 className="mt-4 text-2xl font-bold text-white">Agent Relay</h1>
659
+ <p className="mt-2 text-text-muted">
660
+ {state === 'no-workspaces' ? 'Create a workspace to get started' : 'Select a workspace'}
661
+ </p>
662
+ </div>
663
+
664
+ {error && (
665
+ <div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-xl">
666
+ <p className="text-error">{error}</p>
667
+ </div>
668
+ )}
669
+
670
+ {/* Workspaces list */}
671
+ {state === 'select-workspace' && workspaces.length > 0 && (
672
+ <div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
673
+ <h2 className="text-lg font-semibold text-white mb-4">Your Workspaces</h2>
674
+ <div className="space-y-3">
675
+ {workspaces.map((workspace) => (
676
+ <div
677
+ key={workspace.id}
678
+ className="flex items-center justify-between p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors"
679
+ >
680
+ <div className="flex items-center gap-3">
681
+ <div className={`w-3 h-3 rounded-full ${
682
+ workspace.status === 'running' ? 'bg-success' :
683
+ workspace.status === 'provisioning' ? 'bg-warning animate-pulse' :
684
+ workspace.status === 'error' ? 'bg-error' : 'bg-gray-500'
685
+ }`} />
686
+ <div>
687
+ <h3 className="font-medium text-white">{workspace.name}</h3>
688
+ <p className="text-sm text-text-muted">
689
+ {workspace.status === 'running' ? 'Running' :
690
+ workspace.status === 'provisioning' ? 'Starting...' :
691
+ workspace.status === 'stopped' ? 'Stopped' : 'Error'}
692
+ </p>
693
+ </div>
694
+ </div>
695
+ <div>
696
+ {workspace.status === 'running' && workspace.publicUrl ? (
697
+ <button
698
+ onClick={() => connectToWorkspace(workspace)}
699
+ className="py-2 px-4 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-lg hover:shadow-glow-cyan transition-all"
700
+ >
701
+ Connect
702
+ </button>
703
+ ) : workspace.status === 'stopped' ? (
704
+ <button
705
+ onClick={() => handleStartWorkspace(workspace)}
706
+ className="py-2 px-4 bg-bg-card border border-border-subtle rounded-lg text-white hover:border-accent-cyan/50 transition-colors"
707
+ >
708
+ Start
709
+ </button>
710
+ ) : workspace.status === 'provisioning' ? (
711
+ <span className="text-text-muted text-sm">Starting...</span>
712
+ ) : workspace.status === 'error' ? (
713
+ <div className="flex items-center gap-2">
714
+ <button
715
+ onClick={() => handleStartWorkspace(workspace)}
716
+ className="py-2 px-4 bg-accent-cyan/10 border border-accent-cyan/30 rounded-lg text-accent-cyan text-sm font-medium hover:bg-accent-cyan/20 transition-colors"
717
+ >
718
+ Restart
719
+ </button>
720
+ <a
721
+ href="/app/settings/workspace"
722
+ className="py-2 px-3 text-text-muted text-sm hover:text-white transition-colors"
723
+ title="Workspace settings"
724
+ >
725
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
726
+ <circle cx="12" cy="12" r="3" />
727
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
728
+ </svg>
729
+ </a>
730
+ </div>
731
+ ) : (
732
+ <span className="text-error text-sm">Failed</span>
733
+ )}
734
+ </div>
735
+ </div>
736
+ ))}
737
+ </div>
738
+
739
+ {/* Show Create button only if there are repos without workspaces */}
740
+ {(() => {
741
+ // Filter out repos that already have workspaces
742
+ // Workspace names are like "Workspace for Owner/repo" or just the repo fullName
743
+ // Extract the repo fullName from workspace name or repositories array
744
+ const workspaceRepoFullNames = new Set(
745
+ workspaces.flatMap(w => {
746
+ const names: string[] = [];
747
+ // Check repositories array first
748
+ if (w.repositories && w.repositories.length > 0) {
749
+ w.repositories.forEach(r => names.push(r.toLowerCase()));
750
+ }
751
+ // Also extract from workspace name (format: "Workspace for Owner/repo" or "Owner/repo")
752
+ const match = w.name.match(/(?:Workspace for\s+)?(.+\/.+)/i);
753
+ if (match) {
754
+ names.push(match[1].toLowerCase());
755
+ }
756
+ return names;
757
+ })
758
+ );
759
+
760
+ const availableRepos = repos.filter(repo => {
761
+ return !workspaceRepoFullNames.has(repo.fullName.toLowerCase());
762
+ });
763
+
764
+ if (availableRepos.length === 0) return null;
765
+
766
+ return (
767
+ <div className="mt-6 pt-6 border-t border-border-subtle">
768
+ <p className="text-text-muted text-sm mb-3">Or create a new workspace:</p>
769
+ <button
770
+ onClick={() => setState('create-workspace')}
771
+ className="py-2 px-4 bg-bg-card border border-border-subtle rounded-lg text-sm text-white hover:border-accent-cyan/50 transition-colors flex items-center gap-2"
772
+ >
773
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
774
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
775
+ </svg>
776
+ Create
777
+ </button>
778
+ </div>
779
+ );
780
+ })()}
781
+ </div>
782
+ )}
783
+
784
+ {/* No workspaces - create first one */}
785
+ {state === 'no-workspaces' && (
786
+ <div className="bg-bg-primary/80 backdrop-blur-sm border border-border-subtle rounded-2xl p-6">
787
+ <h2 className="text-lg font-semibold text-white mb-4">Create Your First Workspace</h2>
788
+ <p className="text-text-muted mb-6">
789
+ Select a repository to create a workspace where agents can work on your code.
790
+ </p>
791
+
792
+ {repos.length > 0 ? (
793
+ <div className="space-y-3">
794
+ {repos.map((repo) => (
795
+ <button
796
+ key={repo.id}
797
+ onClick={() => handleCreateWorkspace(repo.fullName)}
798
+ className="w-full flex items-center gap-3 p-4 bg-bg-tertiary rounded-xl border border-border-subtle hover:border-accent-cyan/50 transition-colors text-left"
799
+ >
800
+ <svg className="w-5 h-5 text-text-muted flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
801
+ <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" />
802
+ </svg>
803
+ <div className="flex-1 min-w-0">
804
+ <p className="text-white font-medium truncate">{repo.fullName}</p>
805
+ <p className="text-text-muted text-sm">{repo.isPrivate ? 'Private' : 'Public'}</p>
806
+ </div>
807
+ <svg className="w-5 h-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
808
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
809
+ </svg>
810
+ </button>
811
+ ))}
812
+ </div>
813
+ ) : (
814
+ <div className="text-center py-8">
815
+ <p className="text-text-muted mb-4">No repositories connected yet.</p>
816
+ <a
817
+ href="/connect-repos"
818
+ 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"
819
+ >
820
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
821
+ <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" />
822
+ </svg>
823
+ Connect GitHub
824
+ </a>
825
+ </div>
826
+ )}
827
+ </div>
828
+ )}
829
+
830
+ {/* Navigation */}
831
+ <div className="mt-6 flex justify-center gap-4 text-sm">
832
+ <a href="/connect-repos" className="text-text-muted hover:text-white transition-colors">
833
+ Manage Repositories
834
+ </a>
835
+ <span className="text-text-muted">·</span>
836
+ <button
837
+ onClick={async () => {
838
+ const headers: Record<string, string> = {};
839
+ if (csrfToken) {
840
+ headers['X-CSRF-Token'] = csrfToken;
841
+ }
842
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', headers });
843
+ window.location.href = '/login';
844
+ }}
845
+ className="text-text-muted hover:text-white transition-colors"
846
+ >
847
+ Sign Out
848
+ </button>
849
+ </div>
850
+ </div>
851
+ </div>
852
+ );
853
+ }