@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,1368 @@
1
+ /**
2
+ * Workspace Settings Panel
3
+ *
4
+ * Manage workspace configuration including repositories,
5
+ * AI providers, custom domains, and agent policies.
6
+ *
7
+ * Design: Mission Control theme with deep space aesthetic
8
+ */
9
+
10
+ import React, { useState, useEffect, useCallback } from 'react';
11
+ import { cloudApi } from '../../lib/cloudApi';
12
+ import { ProviderAuthFlow } from '../ProviderAuthFlow';
13
+ import { TerminalProviderSetup } from '../TerminalProviderSetup';
14
+ import { RepositoriesPanel } from '../RepositoriesPanel';
15
+
16
+ export interface WorkspaceSettingsPanelProps {
17
+ workspaceId: string;
18
+ csrfToken?: string;
19
+ onClose?: () => void;
20
+ onReposChanged?: () => void;
21
+ }
22
+
23
+ interface WorkspaceDetails {
24
+ id: string;
25
+ name: string;
26
+ status: string;
27
+ publicUrl?: string;
28
+ computeProvider: string;
29
+ config: {
30
+ providers: string[];
31
+ repositories: string[];
32
+ supervisorEnabled?: boolean;
33
+ maxAgents?: number;
34
+ };
35
+ customDomain?: string;
36
+ customDomainStatus?: string;
37
+ errorMessage?: string;
38
+ repositories: Array<{
39
+ id: string;
40
+ fullName: string;
41
+ syncStatus: string;
42
+ lastSyncedAt?: string;
43
+ }>;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ }
47
+
48
+ interface AvailableRepo {
49
+ id: string;
50
+ fullName: string;
51
+ isPrivate: boolean;
52
+ defaultBranch: string;
53
+ syncStatus: string;
54
+ hasNangoConnection: boolean;
55
+ lastSyncedAt?: string;
56
+ }
57
+
58
+ interface AIProvider {
59
+ id: string;
60
+ name: string;
61
+ displayName: string;
62
+ description: string;
63
+ color: string;
64
+ cliCommand: string;
65
+ apiKeyUrl?: string;
66
+ apiKeyName?: string;
67
+ supportsOAuth?: boolean;
68
+ preferApiKey?: boolean; // Show API key input by default (simpler for mobile/containers)
69
+ isConnected?: boolean;
70
+ comingSoon?: boolean; // Provider is not yet fully tested/available
71
+ }
72
+
73
+ const AI_PROVIDERS: AIProvider[] = [
74
+ {
75
+ id: 'anthropic',
76
+ name: 'anthropic', // Must be lowercase to match backend validation
77
+ displayName: 'Claude',
78
+ description: 'Claude Code - recommended for code tasks',
79
+ color: '#D97757',
80
+ cliCommand: 'claude',
81
+ apiKeyUrl: 'https://console.anthropic.com/settings/keys',
82
+ apiKeyName: 'API key',
83
+ supportsOAuth: true,
84
+ },
85
+ {
86
+ id: 'codex',
87
+ name: 'codex', // Must match backend provider key
88
+ displayName: 'Codex',
89
+ description: 'Codex - OpenAI coding assistant',
90
+ color: '#10A37F',
91
+ cliCommand: 'codex login',
92
+ apiKeyUrl: 'https://platform.openai.com/api-keys',
93
+ apiKeyName: 'API key',
94
+ supportsOAuth: true,
95
+ },
96
+ {
97
+ id: 'google',
98
+ name: 'google', // Must be lowercase to match backend validation
99
+ displayName: 'Gemini',
100
+ description: 'Gemini - Google AI coding assistant',
101
+ color: '#4285F4',
102
+ cliCommand: 'gemini',
103
+ // No apiKeyUrl - Gemini uses interactive terminal where user can choose OAuth or API key
104
+ supportsOAuth: true,
105
+ },
106
+ {
107
+ id: 'opencode',
108
+ name: 'opencode', // Must be lowercase to match backend validation
109
+ displayName: 'OpenCode',
110
+ description: 'OpenCode - AI coding assistant',
111
+ color: '#00D4AA',
112
+ cliCommand: 'opencode',
113
+ supportsOAuth: true,
114
+ comingSoon: true, // Not yet fully tested
115
+ },
116
+ {
117
+ id: 'droid',
118
+ name: 'factory', // Must be lowercase to match backend validation
119
+ displayName: 'Droid',
120
+ description: 'Droid - Factory AI coding agent',
121
+ color: '#6366F1',
122
+ cliCommand: 'droid',
123
+ supportsOAuth: true,
124
+ comingSoon: true, // Not yet fully tested
125
+ },
126
+ {
127
+ id: 'cursor',
128
+ name: 'cursor', // Must be lowercase to match backend validation
129
+ displayName: 'Cursor',
130
+ description: 'Cursor - AI-first code editor agent',
131
+ color: '#7C3AED',
132
+ cliCommand: 'agent',
133
+ supportsOAuth: true,
134
+ },
135
+ ];
136
+
137
+ export function WorkspaceSettingsPanel({
138
+ workspaceId,
139
+ csrfToken,
140
+ onClose,
141
+ onReposChanged,
142
+ }: WorkspaceSettingsPanelProps) {
143
+ const [workspace, setWorkspace] = useState<WorkspaceDetails | null>(null);
144
+ const [availableRepos, setAvailableRepos] = useState<AvailableRepo[]>([]);
145
+ const [isLoading, setIsLoading] = useState(true);
146
+ const [error, setError] = useState<string | null>(null);
147
+ const [activeSection, setActiveSection] = useState<'general' | 'providers' | 'repos' | 'github-access' | 'domain' | 'danger'>('general');
148
+
149
+ // Provider connection state
150
+ const [providerStatus, setProviderStatus] = useState<Record<string, boolean>>({});
151
+ const [connectingProvider, setConnectingProvider] = useState<string | null>(null);
152
+ const [apiKeyInput, setApiKeyInput] = useState('');
153
+ const [providerError, setProviderError] = useState<string | null>(null);
154
+ const [showApiKeyFallback, setShowApiKeyFallback] = useState<Record<string, boolean>>({});
155
+ // Use terminal-based setup (default for Claude, Cursor, and Gemini - Codex uses CLI helper flow)
156
+ const [useTerminalSetup, setUseTerminalSetup] = useState<Record<string, boolean>>({
157
+ anthropic: false, // CLI-assisted SSH tunnel flow for Claude
158
+ cursor: false, // CLI-assisted SSH tunnel flow for Cursor
159
+ google: true, // Default to terminal for Gemini - allows choosing OAuth or API key
160
+ });
161
+
162
+ // CLI command copy state
163
+
164
+ // Provider disconnection state
165
+ const [disconnectingProvider, setDisconnectingProvider] = useState<string | null>(null);
166
+
167
+ // Repo sync state
168
+ const [syncingRepoId, setSyncingRepoId] = useState<string | null>(null);
169
+
170
+ // Custom domain form
171
+ const [customDomain, setCustomDomain] = useState('');
172
+ const [domainLoading, setDomainLoading] = useState(false);
173
+ const [domainError, setDomainError] = useState<string | null>(null);
174
+ const [domainInstructions, setDomainInstructions] = useState<{
175
+ type: string;
176
+ name: string;
177
+ value: string;
178
+ ttl: number;
179
+ } | null>(null);
180
+
181
+ // Load workspace details
182
+ useEffect(() => {
183
+ // Skip loading if workspaceId is invalid (not a UUID)
184
+ if (!workspaceId || workspaceId === 'default' || !/^[0-9a-f-]{36}$/i.test(workspaceId)) {
185
+ setIsLoading(false);
186
+ return;
187
+ }
188
+
189
+ async function loadWorkspace() {
190
+ setIsLoading(true);
191
+ setError(null);
192
+
193
+ const [wsResult, reposResult, providersResult] = await Promise.all([
194
+ cloudApi.getWorkspaceDetails(workspaceId),
195
+ cloudApi.getRepos(),
196
+ cloudApi.getProviders(workspaceId),
197
+ ]);
198
+
199
+ if (wsResult.success) {
200
+ setWorkspace(wsResult.data);
201
+ if (wsResult.data.customDomain) {
202
+ setCustomDomain(wsResult.data.customDomain);
203
+ }
204
+ } else {
205
+ setError(wsResult.error);
206
+ }
207
+
208
+ if (reposResult.success) {
209
+ setAvailableRepos(reposResult.data.repositories);
210
+ }
211
+
212
+ // Mark connected providers for this workspace
213
+ if (providersResult.success) {
214
+ const connected: Record<string, boolean> = {};
215
+ providersResult.data.providers.forEach((p) => {
216
+ if (p.isConnected) {
217
+ connected[p.id] = true;
218
+ // Map backend 'openai' to frontend 'codex' for consistency
219
+ if (p.id === 'openai') {
220
+ connected['codex'] = true;
221
+ }
222
+ }
223
+ });
224
+ setProviderStatus(connected);
225
+ }
226
+
227
+ setIsLoading(false);
228
+ }
229
+
230
+ loadWorkspace();
231
+ }, [workspaceId]);
232
+
233
+ // Start CLI-based OAuth flow for a provider
234
+ // This just sets state to show the ProviderAuthFlow component, which handles the actual auth
235
+ const startOAuthFlow = (provider: AIProvider) => {
236
+ setProviderError(null);
237
+ setConnectingProvider(provider.id);
238
+ // ProviderAuthFlow will handle the rest when it mounts
239
+ };
240
+
241
+ // Disconnect a provider
242
+ const handleDisconnectProvider = useCallback(async (provider: AIProvider) => {
243
+ const confirmed = window.confirm(
244
+ `Are you sure you want to disconnect ${provider.displayName}? This will remove the authentication and delete credential files from the workspace.`
245
+ );
246
+ if (!confirmed) return;
247
+
248
+ setDisconnectingProvider(provider.id);
249
+ setProviderError(null);
250
+
251
+ try {
252
+ const result = await cloudApi.disconnectProvider(provider.id, workspaceId);
253
+ if (result.success) {
254
+ setProviderStatus(prev => {
255
+ const updated = { ...prev };
256
+ delete updated[provider.id];
257
+ return updated;
258
+ });
259
+ } else {
260
+ setProviderError(result.error);
261
+ }
262
+ } catch (err) {
263
+ setProviderError(err instanceof Error ? err.message : 'Failed to disconnect provider');
264
+ } finally {
265
+ setDisconnectingProvider(null);
266
+ }
267
+ }, [workspaceId]);
268
+
269
+ const submitApiKey = async (provider: AIProvider) => {
270
+ if (!apiKeyInput.trim()) {
271
+ setProviderError('Please enter an API key');
272
+ return;
273
+ }
274
+
275
+ setProviderError(null);
276
+ setConnectingProvider(provider.id);
277
+
278
+ try {
279
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
280
+ if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
281
+
282
+ const res = await fetch(`/api/onboarding/token/${provider.id}`, {
283
+ method: 'POST',
284
+ credentials: 'include',
285
+ headers,
286
+ body: JSON.stringify({ token: apiKeyInput.trim(), workspaceId }),
287
+ });
288
+
289
+ if (!res.ok) {
290
+ const data = await res.json();
291
+ throw new Error(data.error || 'Failed to connect');
292
+ }
293
+
294
+ setProviderStatus(prev => ({ ...prev, [provider.id]: true }));
295
+ setApiKeyInput('');
296
+ setConnectingProvider(null);
297
+ setShowApiKeyFallback(prev => ({ ...prev, [provider.id]: false }));
298
+ } catch (err) {
299
+ setProviderError(err instanceof Error ? err.message : 'Failed to connect');
300
+ setConnectingProvider(null);
301
+ }
302
+ };
303
+
304
+ // Restart workspace
305
+ const [isRestarting, setIsRestarting] = useState(false);
306
+ const [isRebuilding, setIsRebuilding] = useState(false);
307
+
308
+ const handleRestart = useCallback(async () => {
309
+ if (!workspace) return;
310
+
311
+ const confirmed = window.confirm('Are you sure you want to restart this workspace?');
312
+ if (!confirmed) return;
313
+
314
+ setIsRestarting(true);
315
+ setError(null);
316
+
317
+ const result = await cloudApi.restartWorkspace(workspace.id);
318
+ if (result.success) {
319
+ // If reprovisioning, update status to show provisioning state
320
+ if (result.data.action === 'reprovisioning') {
321
+ setWorkspace(prev => prev ? { ...prev, status: 'provisioning', errorMessage: undefined } : null);
322
+ }
323
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
324
+ if (wsResult.success) {
325
+ setWorkspace(wsResult.data);
326
+ }
327
+ } else {
328
+ setError(result.error);
329
+ }
330
+ setIsRestarting(false);
331
+ }, [workspace, workspaceId]);
332
+
333
+ // Rebuild workspace from scratch
334
+ const handleRebuild = useCallback(async () => {
335
+ if (!workspace) return;
336
+
337
+ const confirmed = window.confirm(
338
+ 'This will completely rebuild your workspace from scratch. All running processes will be stopped. Continue?'
339
+ );
340
+ if (!confirmed) return;
341
+
342
+ setIsRebuilding(true);
343
+ setError(null);
344
+
345
+ const result = await cloudApi.rebuildWorkspace(workspace.id);
346
+ if (result.success) {
347
+ setWorkspace(prev => prev ? { ...prev, status: 'provisioning', errorMessage: undefined } : null);
348
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
349
+ if (wsResult.success) {
350
+ setWorkspace(wsResult.data);
351
+ }
352
+ } else {
353
+ setError(result.error);
354
+ }
355
+ setIsRebuilding(false);
356
+ }, [workspace, workspaceId]);
357
+
358
+ // Stop workspace
359
+ const handleStop = useCallback(async () => {
360
+ if (!workspace) return;
361
+
362
+ const confirmed = window.confirm('Are you sure you want to stop this workspace?');
363
+ if (!confirmed) return;
364
+
365
+ const result = await cloudApi.stopWorkspace(workspace.id);
366
+ if (result.success) {
367
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
368
+ if (wsResult.success) {
369
+ setWorkspace(wsResult.data);
370
+ }
371
+ } else {
372
+ setError(result.error);
373
+ }
374
+ }, [workspace, workspaceId]);
375
+
376
+ // Add repository to workspace
377
+ const handleAddRepo = useCallback(async (repoId: string) => {
378
+ if (!workspace) return;
379
+
380
+ const result = await cloudApi.addReposToWorkspace(workspace.id, [repoId]);
381
+ if (result.success) {
382
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
383
+ if (wsResult.success) {
384
+ setWorkspace(wsResult.data);
385
+ }
386
+ } else {
387
+ setError(result.error);
388
+ }
389
+ }, [workspace, workspaceId]);
390
+
391
+ // Sync repository to workspace (clone/pull)
392
+ const handleSyncRepo = useCallback(async (repoId: string) => {
393
+ if (!workspace) return;
394
+
395
+ setSyncingRepoId(repoId);
396
+ setError(null);
397
+
398
+ const result = await cloudApi.syncRepo(repoId);
399
+ if (result.success) {
400
+ // Refresh workspace to get updated sync status
401
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
402
+ if (wsResult.success) {
403
+ setWorkspace(wsResult.data);
404
+ }
405
+ } else {
406
+ setError(result.error);
407
+ }
408
+
409
+ setSyncingRepoId(null);
410
+ }, [workspace, workspaceId]);
411
+
412
+ // Set custom domain
413
+ const handleSetDomain = useCallback(async () => {
414
+ if (!workspace || !customDomain.trim()) return;
415
+
416
+ setDomainLoading(true);
417
+ setDomainError(null);
418
+ setDomainInstructions(null);
419
+
420
+ const result = await cloudApi.setCustomDomain(workspace.id, customDomain.trim());
421
+ if (result.success) {
422
+ setDomainInstructions(result.data.instructions);
423
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
424
+ if (wsResult.success) {
425
+ setWorkspace(wsResult.data);
426
+ }
427
+ } else {
428
+ setDomainError(result.error);
429
+ }
430
+
431
+ setDomainLoading(false);
432
+ }, [workspace, customDomain, workspaceId]);
433
+
434
+ // Verify custom domain
435
+ const handleVerifyDomain = useCallback(async () => {
436
+ if (!workspace) return;
437
+
438
+ setDomainLoading(true);
439
+ setDomainError(null);
440
+
441
+ const result = await cloudApi.verifyCustomDomain(workspace.id);
442
+ if (result.success) {
443
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
444
+ if (wsResult.success) {
445
+ setWorkspace(wsResult.data);
446
+ }
447
+ if (result.data.status === 'active') {
448
+ setDomainInstructions(null);
449
+ }
450
+ } else {
451
+ setDomainError(result.error);
452
+ }
453
+
454
+ setDomainLoading(false);
455
+ }, [workspace, workspaceId]);
456
+
457
+ // Remove custom domain
458
+ const handleRemoveDomain = useCallback(async () => {
459
+ if (!workspace) return;
460
+
461
+ const confirmed = window.confirm('Are you sure you want to remove the custom domain?');
462
+ if (!confirmed) return;
463
+
464
+ setDomainLoading(true);
465
+ const result = await cloudApi.removeCustomDomain(workspace.id);
466
+ if (result.success) {
467
+ setCustomDomain('');
468
+ setDomainInstructions(null);
469
+ const wsResult = await cloudApi.getWorkspaceDetails(workspaceId);
470
+ if (wsResult.success) {
471
+ setWorkspace(wsResult.data);
472
+ }
473
+ } else {
474
+ setDomainError(result.error);
475
+ }
476
+ setDomainLoading(false);
477
+ }, [workspace, workspaceId]);
478
+
479
+ // Delete workspace
480
+ const handleDelete = useCallback(async () => {
481
+ if (!workspace) return;
482
+
483
+ const confirmed = window.confirm(
484
+ `Are you sure you want to delete "${workspace.name}"? This action cannot be undone.`
485
+ );
486
+ if (!confirmed) return;
487
+
488
+ const doubleConfirm = window.confirm(
489
+ 'This will permanently delete all workspace data. Are you absolutely sure?'
490
+ );
491
+ if (!doubleConfirm) return;
492
+
493
+ const result = await cloudApi.deleteWorkspace(workspace.id);
494
+ if (result.success) {
495
+ // Redirect to onboarding page with deleted reason
496
+ window.location.href = '/app/onboarding?reason=deleted';
497
+ } else {
498
+ setError(result.error);
499
+ }
500
+ }, [workspace]);
501
+
502
+ if (isLoading) {
503
+ return (
504
+ <div className="flex items-center justify-center h-64">
505
+ <div className="relative">
506
+ <div className="w-12 h-12 rounded-full border-2 border-accent-cyan/20 border-t-accent-cyan animate-spin" />
507
+ <div className="absolute inset-0 flex items-center justify-center">
508
+ <div className="w-4 h-4 rounded-full bg-accent-cyan/40 animate-pulse" />
509
+ </div>
510
+ </div>
511
+ <span className="ml-4 text-text-muted font-mono text-sm tracking-wide">
512
+ LOADING WORKSPACE CONFIG...
513
+ </span>
514
+ </div>
515
+ );
516
+ }
517
+
518
+ if (error && !workspace) {
519
+ return (
520
+ <div className="p-6">
521
+ <div className="p-4 bg-error/10 border border-error/30 rounded-lg text-error flex items-center gap-3">
522
+ <AlertIcon />
523
+ <span>{error}</span>
524
+ </div>
525
+ </div>
526
+ );
527
+ }
528
+
529
+ if (!workspace) {
530
+ return null;
531
+ }
532
+
533
+ const unassignedRepos = availableRepos.filter(
534
+ (r) => !workspace.repositories.some((wr) => wr.id === r.id)
535
+ );
536
+
537
+ const sections = [
538
+ { id: 'general', label: 'General', icon: <SettingsGearIcon /> },
539
+ { id: 'providers', label: 'AI Providers', icon: <ProviderIcon /> },
540
+ { id: 'repos', label: 'Repositories', icon: <RepoIcon /> },
541
+ { id: 'domain', label: 'Domain', icon: <GlobeIcon /> },
542
+ { id: 'danger', label: 'Danger', icon: <AlertIcon /> },
543
+ ];
544
+
545
+ return (
546
+ <div className="flex flex-col h-full bg-bg-primary">
547
+ {/* Section Navigation - horizontally scrollable on mobile */}
548
+ <div
549
+ className="flex gap-1 p-2 sm:p-3 border-b border-border-subtle bg-gradient-to-b from-bg-tertiary to-bg-primary overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory touch-pan-x"
550
+ style={{ WebkitOverflowScrolling: 'touch' }}
551
+ >
552
+ {sections.map((section) => (
553
+ <button
554
+ key={section.id}
555
+ onClick={() => setActiveSection(section.id as typeof activeSection)}
556
+ className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-medium transition-all duration-200 whitespace-nowrap shrink-0 snap-start ${
557
+ activeSection === section.id
558
+ ? 'bg-accent-cyan/15 text-accent-cyan border border-accent-cyan/30 shadow-[0_0_12px_rgba(0,217,255,0.15)]'
559
+ : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary border border-transparent'
560
+ }`}
561
+ >
562
+ <span className={activeSection === section.id ? 'text-accent-cyan' : 'text-text-muted'}>
563
+ {section.icon}
564
+ </span>
565
+ {section.label}
566
+ </button>
567
+ ))}
568
+ </div>
569
+
570
+ {/* Content */}
571
+ <div className="flex-1 overflow-y-auto p-4 sm:p-6">
572
+ {error && (
573
+ <div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-lg text-error text-sm flex items-center gap-3">
574
+ <AlertIcon />
575
+ <span className="flex-1">{error}</span>
576
+ <button onClick={() => setError(null)} className="text-error/60 hover:text-error">
577
+ <CloseIcon />
578
+ </button>
579
+ </div>
580
+ )}
581
+
582
+ {/* General Section */}
583
+ {activeSection === 'general' && (
584
+ <div className="space-y-8">
585
+ <SectionHeader
586
+ title="Workspace Overview"
587
+ subtitle="Core configuration and status"
588
+ />
589
+
590
+ {/* Error state banner */}
591
+ {workspace.status === 'error' && (
592
+ <div className="p-5 bg-error/10 border border-error/30 rounded-xl space-y-4">
593
+ <div className="flex items-start gap-3">
594
+ <div className="w-10 h-10 rounded-lg bg-error/20 flex items-center justify-center shrink-0 mt-0.5">
595
+ <AlertIcon className="text-error" />
596
+ </div>
597
+ <div className="flex-1">
598
+ <h4 className="text-sm font-semibold text-error">Workspace Error</h4>
599
+ <p className="text-xs text-text-secondary mt-1">
600
+ {workspace.errorMessage || 'The workspace encountered an error and is not running.'}
601
+ </p>
602
+ </div>
603
+ </div>
604
+ <div className="flex gap-3">
605
+ <ActionButton
606
+ onClick={handleRestart}
607
+ disabled={isRestarting || isRebuilding}
608
+ variant="primary"
609
+ icon={isRestarting ? <SpinnerIcon /> : <RestartIcon />}
610
+ >
611
+ {isRestarting ? 'Restarting...' : 'Restart Workspace'}
612
+ </ActionButton>
613
+ <ActionButton
614
+ onClick={handleRebuild}
615
+ disabled={isRestarting || isRebuilding}
616
+ variant="warning"
617
+ icon={isRebuilding ? <SpinnerIcon /> : <RebuildIcon />}
618
+ >
619
+ {isRebuilding ? 'Rebuilding...' : 'Rebuild from Scratch'}
620
+ </ActionButton>
621
+ </div>
622
+ </div>
623
+ )}
624
+
625
+ <div className="grid grid-cols-2 gap-4">
626
+ <InfoCard label="Name" value={workspace.name} />
627
+ <InfoCard
628
+ label="Status"
629
+ value={
630
+ (isRestarting || isRebuilding) ? 'Provisioning' :
631
+ workspace.status.charAt(0).toUpperCase() + workspace.status.slice(1)
632
+ }
633
+ valueColor={
634
+ (isRestarting || isRebuilding) ? 'text-accent-cyan' :
635
+ workspace.status === 'running' ? 'text-success' :
636
+ workspace.status === 'stopped' ? 'text-amber-400' :
637
+ workspace.status === 'error' ? 'text-error' : 'text-text-muted'
638
+ }
639
+ indicator={workspace.status === 'running' && !isRestarting && !isRebuilding}
640
+ />
641
+ <InfoCard
642
+ label="Public URL"
643
+ value={workspace.publicUrl || 'Not available'}
644
+ mono
645
+ />
646
+ <InfoCard
647
+ label="Compute Provider"
648
+ value={workspace.computeProvider.charAt(0).toUpperCase() + workspace.computeProvider.slice(1)}
649
+ />
650
+ </div>
651
+
652
+ <div>
653
+ <SectionHeader title="Actions" subtitle="Manage workspace state" />
654
+ <div className="flex flex-wrap gap-3 mt-4">
655
+ {workspace.status === 'running' && (
656
+ <ActionButton
657
+ onClick={handleStop}
658
+ variant="warning"
659
+ icon={<StopIcon />}
660
+ >
661
+ Stop Workspace
662
+ </ActionButton>
663
+ )}
664
+ <ActionButton
665
+ onClick={handleRestart}
666
+ disabled={isRestarting || isRebuilding}
667
+ variant="primary"
668
+ icon={isRestarting ? <SpinnerIcon /> : <RestartIcon />}
669
+ >
670
+ {isRestarting ? 'Restarting...' : 'Restart Workspace'}
671
+ </ActionButton>
672
+ <ActionButton
673
+ onClick={handleRebuild}
674
+ disabled={isRestarting || isRebuilding}
675
+ variant="danger"
676
+ icon={isRebuilding ? <SpinnerIcon /> : <RebuildIcon />}
677
+ >
678
+ {isRebuilding ? 'Rebuilding...' : 'Rebuild Workspace'}
679
+ </ActionButton>
680
+ </div>
681
+ </div>
682
+ </div>
683
+ )}
684
+
685
+ {/* AI Providers Section */}
686
+ {activeSection === 'providers' && (
687
+ <div className="space-y-8">
688
+ <SectionHeader
689
+ title="AI Providers"
690
+ subtitle="Connect AI providers to spawn agents in this workspace"
691
+ />
692
+
693
+ {providerError && (
694
+ <div className="p-4 bg-error/10 border border-error/30 rounded-lg text-error text-sm flex items-center gap-3">
695
+ <AlertIcon />
696
+ <span>{providerError}</span>
697
+ </div>
698
+ )}
699
+
700
+ <div className="space-y-4">
701
+ {AI_PROVIDERS.map((provider) => (
702
+ <div
703
+ key={provider.id}
704
+ className={`p-5 bg-bg-tertiary rounded-xl border border-border-subtle transition-all duration-200 ${
705
+ provider.comingSoon ? 'opacity-60' : 'hover:border-border-medium'
706
+ }`}
707
+ >
708
+ <div className="flex items-center justify-between">
709
+ <div className="flex items-center gap-4">
710
+ <div
711
+ className={`w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg ${
712
+ provider.comingSoon ? 'grayscale' : ''
713
+ }`}
714
+ style={{
715
+ backgroundColor: provider.color,
716
+ boxShadow: provider.comingSoon ? 'none' : `0 4px 20px ${provider.color}40`,
717
+ }}
718
+ >
719
+ {provider.displayName[0]}
720
+ </div>
721
+ <div>
722
+ <h4 className="text-base font-semibold text-text-primary flex items-center gap-2">
723
+ {provider.displayName}
724
+ {provider.comingSoon && (
725
+ <span className="px-2 py-0.5 bg-amber-400/20 text-amber-400 text-xs font-medium rounded-full">
726
+ Coming Soon
727
+ </span>
728
+ )}
729
+ </h4>
730
+ <p className="text-sm text-text-muted">{provider.description}</p>
731
+ </div>
732
+ </div>
733
+
734
+ {provider.comingSoon ? (
735
+ <div className="px-4 py-2 bg-bg-card rounded-full border border-border-subtle">
736
+ <span className="text-sm text-text-muted">Not available yet</span>
737
+ </div>
738
+ ) : providerStatus[provider.id] ? (
739
+ <div className="flex items-center gap-3">
740
+ <div className="flex items-center gap-2 px-4 py-2 bg-success/15 rounded-full border border-success/30">
741
+ <div className="w-2 h-2 rounded-full bg-success animate-pulse" />
742
+ <span className="text-sm font-medium text-success">Connected</span>
743
+ </div>
744
+ <button
745
+ onClick={() => handleDisconnectProvider(provider)}
746
+ disabled={disconnectingProvider === provider.id}
747
+ className="px-3 py-2 text-xs font-medium text-error/80 hover:text-error hover:bg-error/10 rounded-lg border border-transparent hover:border-error/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
748
+ title={`Disconnect ${provider.displayName}`}
749
+ >
750
+ {disconnectingProvider === provider.id ? 'Disconnecting...' : 'Disconnect'}
751
+ </button>
752
+ </div>
753
+ ) : null}
754
+ </div>
755
+
756
+ {!providerStatus[provider.id] && !provider.comingSoon && (
757
+ <div className="mt-5 pt-5 border-t border-border-subtle">
758
+ {connectingProvider === provider.id && !showApiKeyFallback[provider.id] ? (
759
+ useTerminalSetup[provider.id] ? (
760
+ <TerminalProviderSetup
761
+ provider={{
762
+ id: provider.id,
763
+ name: provider.name,
764
+ displayName: provider.displayName,
765
+ color: provider.color,
766
+ }}
767
+ workspaceId={workspaceId}
768
+ csrfToken={csrfToken}
769
+ maxHeight="350px"
770
+ onSuccess={() => {
771
+ setProviderStatus(prev => ({ ...prev, [provider.id]: true }));
772
+ setConnectingProvider(null);
773
+ }}
774
+ onCancel={() => {
775
+ setConnectingProvider(null);
776
+ }}
777
+ onError={(err) => {
778
+ setProviderError(err);
779
+ setConnectingProvider(null);
780
+ }}
781
+ onConnectAnother={() => {
782
+ // Mark current provider as connected and clear selection
783
+ // User can then click another provider to connect
784
+ setProviderStatus(prev => ({ ...prev, [provider.id]: true }));
785
+ setConnectingProvider(null);
786
+ }}
787
+ />
788
+ ) : (
789
+ <ProviderAuthFlow
790
+ provider={{
791
+ id: provider.id,
792
+ name: provider.name,
793
+ displayName: provider.displayName,
794
+ color: provider.color,
795
+ requiresUrlCopy: ['codex', 'anthropic', 'cursor'].includes(provider.id),
796
+ }}
797
+ workspaceId={workspaceId}
798
+ csrfToken={csrfToken}
799
+ onSuccess={() => {
800
+ setProviderStatus(prev => ({ ...prev, [provider.id]: true }));
801
+ setConnectingProvider(null);
802
+ }}
803
+ onCancel={() => {
804
+ setConnectingProvider(null);
805
+ }}
806
+ onError={(err) => {
807
+ setProviderError(err);
808
+ setConnectingProvider(null);
809
+ }}
810
+ />
811
+ )
812
+ ) : showApiKeyFallback[provider.id] ? (
813
+ <div className="space-y-4">
814
+ <div className="flex gap-3">
815
+ <input
816
+ type="password"
817
+ placeholder={`Enter ${provider.displayName} ${provider.apiKeyName || 'API key'}`}
818
+ value={connectingProvider === provider.id ? apiKeyInput : ''}
819
+ onChange={(e) => {
820
+ setConnectingProvider(provider.id);
821
+ setApiKeyInput(e.target.value);
822
+ }}
823
+ onFocus={() => setConnectingProvider(provider.id)}
824
+ className="flex-1 px-4 py-3 bg-bg-card border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30 transition-all"
825
+ />
826
+ <button
827
+ onClick={() => submitApiKey(provider)}
828
+ disabled={connectingProvider !== provider.id || !apiKeyInput.trim()}
829
+ className="px-5 py-3 bg-accent-cyan text-bg-deep font-semibold rounded-lg text-sm hover:bg-accent-cyan/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
830
+ >
831
+ Connect
832
+ </button>
833
+ </div>
834
+ {provider.apiKeyUrl && (
835
+ <p className="text-xs text-text-muted">
836
+ Get your API key from{' '}
837
+ <a
838
+ href={provider.apiKeyUrl}
839
+ target="_blank"
840
+ rel="noopener noreferrer"
841
+ className="text-accent-cyan hover:underline"
842
+ >
843
+ {new URL(provider.apiKeyUrl).hostname}
844
+ </a>
845
+ </p>
846
+ )}
847
+ {provider.supportsOAuth && (
848
+ <button
849
+ onClick={() => setShowApiKeyFallback(prev => ({ ...prev, [provider.id]: false }))}
850
+ className="text-xs text-text-muted hover:text-text-secondary transition-colors"
851
+ >
852
+ ← Back to OAuth login
853
+ </button>
854
+ )}
855
+ </div>
856
+ ) : provider.supportsOAuth ? (
857
+ <div className="space-y-3">
858
+ {/* CLI info for providers using SSH tunnel auth */}
859
+ {['codex', 'anthropic', 'cursor'].includes(provider.id) && (
860
+ <div className="p-3 bg-accent-cyan/10 border border-accent-cyan/30 rounded-lg">
861
+ <p className="text-sm text-accent-cyan font-medium mb-1">CLI-assisted authentication</p>
862
+ <p className="text-xs text-accent-cyan/80">
863
+ Click the button below to get a CLI command with a unique session token.
864
+ Run it on your local machine to authenticate with {provider.displayName} via a secure SSH tunnel.
865
+ </p>
866
+ </div>
867
+ )}
868
+ <button
869
+ onClick={() => startOAuthFlow(provider)}
870
+ disabled={connectingProvider !== null}
871
+ className="w-full py-3 px-4 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold rounded-lg text-sm hover:shadow-glow-cyan hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none transition-all duration-200 flex items-center justify-center gap-2"
872
+ >
873
+ <LockIcon />
874
+ Connect with {provider.displayName}
875
+ </button>
876
+ {provider.apiKeyUrl && (
877
+ <button
878
+ onClick={() => setShowApiKeyFallback(prev => ({ ...prev, [provider.id]: true }))}
879
+ className="w-full text-xs text-text-muted hover:text-text-secondary transition-colors"
880
+ >
881
+ Or enter API key manually
882
+ </button>
883
+ )}
884
+ </div>
885
+ ) : (
886
+ /* Provider doesn't support OAuth - show API key input directly */
887
+ <div className="space-y-4">
888
+ <div className="flex gap-3">
889
+ <input
890
+ type="password"
891
+ placeholder={`Enter ${provider.displayName} ${provider.apiKeyName || 'API key'}`}
892
+ value={connectingProvider === provider.id ? apiKeyInput : ''}
893
+ onChange={(e) => {
894
+ setConnectingProvider(provider.id);
895
+ setApiKeyInput(e.target.value);
896
+ }}
897
+ onFocus={() => setConnectingProvider(provider.id)}
898
+ className="flex-1 px-4 py-3 bg-bg-card border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30 transition-all"
899
+ />
900
+ <button
901
+ onClick={() => submitApiKey(provider)}
902
+ disabled={connectingProvider !== provider.id || !apiKeyInput.trim()}
903
+ className="px-5 py-3 bg-accent-cyan text-bg-deep font-semibold rounded-lg text-sm hover:bg-accent-cyan/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
904
+ >
905
+ Connect
906
+ </button>
907
+ </div>
908
+ {provider.apiKeyUrl && (
909
+ <p className="text-xs text-text-muted">
910
+ Get your API key from{' '}
911
+ <a
912
+ href={provider.apiKeyUrl}
913
+ target="_blank"
914
+ rel="noopener noreferrer"
915
+ className="text-accent-cyan hover:underline"
916
+ >
917
+ {new URL(provider.apiKeyUrl).hostname}
918
+ </a>
919
+ </p>
920
+ )}
921
+ <p className="text-xs text-amber-400/80">
922
+ OAuth not available for {provider.displayName} in container environments
923
+ </p>
924
+ </div>
925
+ )}
926
+ </div>
927
+ )}
928
+
929
+ </div>
930
+ ))}
931
+ </div>
932
+ </div>
933
+ )}
934
+
935
+ {/* Repositories Section */}
936
+ {activeSection === 'repos' && (
937
+ <div className="space-y-6">
938
+ <SectionHeader
939
+ title="Repositories"
940
+ subtitle="Manage repositories for this workspace"
941
+ />
942
+ <RepositoriesPanel
943
+ workspaceId={workspaceId}
944
+ workspaceRepos={workspace.repositories}
945
+ onRepoAdded={() => {
946
+ // Refresh workspace data after adding a repo
947
+ cloudApi.getWorkspaceDetails(workspaceId).then(result => {
948
+ if (result.success) {
949
+ setWorkspace(result.data);
950
+ }
951
+ });
952
+ onReposChanged?.();
953
+ }}
954
+ onRepoRemoved={() => {
955
+ // Refresh workspace data after removing a repo
956
+ cloudApi.getWorkspaceDetails(workspaceId).then(result => {
957
+ if (result.success) {
958
+ setWorkspace(result.data);
959
+ }
960
+ });
961
+ onReposChanged?.();
962
+ }}
963
+ csrfToken={csrfToken}
964
+ className="bg-bg-tertiary rounded-xl border border-border-subtle overflow-hidden"
965
+ />
966
+ </div>
967
+ )}
968
+
969
+ {/* Custom Domain Section */}
970
+ {activeSection === 'domain' && (
971
+ <div className="space-y-8">
972
+ <SectionHeader
973
+ title="Custom Domain"
974
+ subtitle="Connect your own domain to this workspace"
975
+ />
976
+
977
+ <div className="p-5 bg-gradient-to-r from-accent-purple/10 to-accent-cyan/10 border border-accent-purple/20 rounded-xl">
978
+ <div className="flex items-center gap-3 mb-3">
979
+ <div className="w-10 h-10 rounded-lg bg-accent-purple/20 flex items-center justify-center">
980
+ <GlobeIcon className="text-accent-purple" />
981
+ </div>
982
+ <div>
983
+ <h4 className="text-sm font-semibold text-text-primary">Premium Feature</h4>
984
+ <p className="text-xs text-text-secondary">Requires Team or Enterprise plan</p>
985
+ </div>
986
+ </div>
987
+ </div>
988
+
989
+ {workspace.customDomain ? (
990
+ <div className="space-y-4">
991
+ <div className="p-5 bg-bg-tertiary rounded-xl border border-border-subtle">
992
+ <div className="flex items-center justify-between mb-3">
993
+ <span className="text-xs text-text-muted uppercase tracking-wide font-semibold">
994
+ Current Domain
995
+ </span>
996
+ <StatusBadge status={workspace.customDomainStatus || 'pending'} />
997
+ </div>
998
+ <p className="text-lg font-mono text-text-primary">{workspace.customDomain}</p>
999
+ </div>
1000
+
1001
+ {workspace.customDomainStatus === 'pending' && (
1002
+ <ActionButton
1003
+ onClick={handleVerifyDomain}
1004
+ disabled={domainLoading}
1005
+ variant="primary"
1006
+ icon={<CheckIcon />}
1007
+ fullWidth
1008
+ >
1009
+ {domainLoading ? 'Verifying...' : 'Verify DNS Configuration'}
1010
+ </ActionButton>
1011
+ )}
1012
+
1013
+ <ActionButton
1014
+ onClick={handleRemoveDomain}
1015
+ disabled={domainLoading}
1016
+ variant="danger"
1017
+ icon={<TrashIcon />}
1018
+ fullWidth
1019
+ >
1020
+ Remove Custom Domain
1021
+ </ActionButton>
1022
+ </div>
1023
+ ) : (
1024
+ <div className="space-y-4">
1025
+ <div>
1026
+ <label className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2 block">
1027
+ Domain Name
1028
+ </label>
1029
+ <input
1030
+ type="text"
1031
+ value={customDomain}
1032
+ onChange={(e) => setCustomDomain(e.target.value)}
1033
+ placeholder="workspace.yourdomain.com"
1034
+ className="w-full px-4 py-3 bg-bg-tertiary border border-border-subtle rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30 transition-all"
1035
+ />
1036
+ </div>
1037
+
1038
+ <ActionButton
1039
+ onClick={handleSetDomain}
1040
+ disabled={domainLoading || !customDomain.trim()}
1041
+ variant="primary"
1042
+ icon={<GlobeIcon />}
1043
+ fullWidth
1044
+ >
1045
+ {domainLoading ? 'Setting up...' : 'Set Custom Domain'}
1046
+ </ActionButton>
1047
+ </div>
1048
+ )}
1049
+
1050
+ {domainError && (
1051
+ <div className="p-4 bg-error/10 border border-error/30 rounded-lg text-error text-sm">
1052
+ {domainError}
1053
+ </div>
1054
+ )}
1055
+
1056
+ {domainInstructions && (
1057
+ <div className="p-5 bg-bg-tertiary rounded-xl border border-border-subtle space-y-4">
1058
+ <h4 className="text-sm font-semibold text-text-primary flex items-center gap-2">
1059
+ <InfoIcon />
1060
+ DNS Configuration Required
1061
+ </h4>
1062
+ <p className="text-xs text-text-secondary">
1063
+ Add the following DNS record to your domain provider:
1064
+ </p>
1065
+ <div className="grid grid-cols-3 gap-3">
1066
+ <DNSField label="Type" value={domainInstructions.type} />
1067
+ <DNSField label="Name" value={domainInstructions.name} />
1068
+ <DNSField label="Value" value={domainInstructions.value} />
1069
+ </div>
1070
+ </div>
1071
+ )}
1072
+ </div>
1073
+ )}
1074
+
1075
+ {/* Danger Zone Section */}
1076
+ {activeSection === 'danger' && (
1077
+ <div className="space-y-8">
1078
+ <div className="p-6 bg-error/5 border-2 border-error/20 rounded-xl">
1079
+ <div className="flex items-center gap-3 mb-4">
1080
+ <div className="w-10 h-10 rounded-lg bg-error/20 flex items-center justify-center">
1081
+ <AlertIcon className="text-error" />
1082
+ </div>
1083
+ <div>
1084
+ <h3 className="text-base font-semibold text-error">Danger Zone</h3>
1085
+ <p className="text-xs text-text-secondary">
1086
+ These actions are destructive and cannot be undone
1087
+ </p>
1088
+ </div>
1089
+ </div>
1090
+
1091
+ <div className="p-5 border border-error/30 rounded-lg bg-bg-primary">
1092
+ <div className="flex items-center justify-between">
1093
+ <div>
1094
+ <h4 className="text-sm font-semibold text-text-primary">Delete Workspace</h4>
1095
+ <p className="text-xs text-text-muted mt-1">
1096
+ Permanently delete this workspace and all its data
1097
+ </p>
1098
+ </div>
1099
+ <button
1100
+ onClick={handleDelete}
1101
+ className="px-5 py-2.5 bg-error text-white rounded-lg text-sm font-semibold hover:bg-error/90 transition-colors"
1102
+ >
1103
+ Delete Workspace
1104
+ </button>
1105
+ </div>
1106
+ </div>
1107
+ </div>
1108
+ </div>
1109
+ )}
1110
+ </div>
1111
+ </div>
1112
+ );
1113
+ }
1114
+
1115
+ // Utility Components
1116
+ function SectionHeader({ title, subtitle }: { title: string; subtitle: string }) {
1117
+ return (
1118
+ <div className="mb-4">
1119
+ <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wide">{title}</h3>
1120
+ <p className="text-xs text-text-muted mt-1">{subtitle}</p>
1121
+ </div>
1122
+ );
1123
+ }
1124
+
1125
+ function InfoCard({
1126
+ label,
1127
+ value,
1128
+ valueColor = 'text-text-primary',
1129
+ mono = false,
1130
+ indicator = false,
1131
+ }: {
1132
+ label: string;
1133
+ value: string;
1134
+ valueColor?: string;
1135
+ mono?: boolean;
1136
+ indicator?: boolean;
1137
+ }) {
1138
+ return (
1139
+ <div className="p-4 bg-bg-tertiary rounded-lg border border-border-subtle">
1140
+ <label className="text-xs text-text-muted uppercase tracking-wide font-medium">{label}</label>
1141
+ <div className="flex items-center gap-2 mt-1">
1142
+ {indicator && <div className="w-2 h-2 rounded-full bg-success animate-pulse" />}
1143
+ <p className={`text-sm font-medium ${valueColor} ${mono ? 'font-mono' : ''} break-all`}>
1144
+ {value}
1145
+ </p>
1146
+ </div>
1147
+ </div>
1148
+ );
1149
+ }
1150
+
1151
+ function ActionButton({
1152
+ children,
1153
+ onClick,
1154
+ disabled,
1155
+ variant,
1156
+ icon,
1157
+ fullWidth,
1158
+ }: {
1159
+ children: React.ReactNode;
1160
+ onClick: () => void;
1161
+ disabled?: boolean;
1162
+ variant: 'primary' | 'warning' | 'danger';
1163
+ icon?: React.ReactNode;
1164
+ fullWidth?: boolean;
1165
+ }) {
1166
+ const variants = {
1167
+ primary: 'bg-accent-cyan/10 border-accent-cyan/30 text-accent-cyan hover:bg-accent-cyan/20',
1168
+ warning: 'bg-amber-400/10 border-amber-400/30 text-amber-400 hover:bg-amber-400/20',
1169
+ danger: 'bg-error/10 border-error/30 text-error hover:bg-error/20',
1170
+ };
1171
+
1172
+ return (
1173
+ <button
1174
+ onClick={onClick}
1175
+ disabled={disabled}
1176
+ className={`${fullWidth ? 'w-full' : ''} px-5 py-2.5 border rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 ${variants[variant]}`}
1177
+ >
1178
+ {icon}
1179
+ {children}
1180
+ </button>
1181
+ );
1182
+ }
1183
+
1184
+ function StatusBadge({ status }: { status: string }) {
1185
+ const styles: Record<string, string> = {
1186
+ synced: 'bg-success/15 text-success border-success/30',
1187
+ active: 'bg-success/15 text-success border-success/30',
1188
+ syncing: 'bg-accent-cyan/15 text-accent-cyan border-accent-cyan/30',
1189
+ verifying: 'bg-accent-cyan/15 text-accent-cyan border-accent-cyan/30',
1190
+ pending: 'bg-amber-400/15 text-amber-400 border-amber-400/30',
1191
+ error: 'bg-error/15 text-error border-error/30',
1192
+ };
1193
+
1194
+ return (
1195
+ <span className={`text-xs px-3 py-1 rounded-full border ${styles[status] || 'bg-bg-hover text-text-muted border-border-subtle'}`}>
1196
+ {status}
1197
+ </span>
1198
+ );
1199
+ }
1200
+
1201
+ function DNSField({ label, value }: { label: string; value: string }) {
1202
+ return (
1203
+ <div className="p-3 bg-bg-card rounded-lg">
1204
+ <label className="text-xs text-text-muted block mb-1">{label}</label>
1205
+ <p className="font-mono text-sm text-text-primary break-all">{value}</p>
1206
+ </div>
1207
+ );
1208
+ }
1209
+
1210
+ // Icons
1211
+ function SettingsGearIcon() {
1212
+ return (
1213
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1214
+ <circle cx="12" cy="12" r="3" />
1215
+ <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" />
1216
+ </svg>
1217
+ );
1218
+ }
1219
+
1220
+ function ProviderIcon() {
1221
+ return (
1222
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1223
+ <path d="M12 2L2 7l10 5 10-5-10-5z" />
1224
+ <path d="M2 17l10 5 10-5" />
1225
+ <path d="M2 12l10 5 10-5" />
1226
+ </svg>
1227
+ );
1228
+ }
1229
+
1230
+ function RepoIcon({ className = '' }: { className?: string }) {
1231
+ return (
1232
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`text-text-muted ${className}`}>
1233
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
1234
+ </svg>
1235
+ );
1236
+ }
1237
+
1238
+ function GlobeIcon({ className = '' }: { className?: string }) {
1239
+ return (
1240
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
1241
+ <circle cx="12" cy="12" r="10" />
1242
+ <line x1="2" y1="12" x2="22" y2="12" />
1243
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
1244
+ </svg>
1245
+ );
1246
+ }
1247
+
1248
+ function GitHubIcon() {
1249
+ return (
1250
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
1251
+ <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" />
1252
+ </svg>
1253
+ );
1254
+ }
1255
+
1256
+ function AlertIcon({ className = '' }: { className?: string }) {
1257
+ return (
1258
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
1259
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
1260
+ <line x1="12" y1="9" x2="12" y2="13" />
1261
+ <line x1="12" y1="17" x2="12.01" y2="17" />
1262
+ </svg>
1263
+ );
1264
+ }
1265
+
1266
+ function LockIcon() {
1267
+ return (
1268
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1269
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1270
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
1271
+ </svg>
1272
+ );
1273
+ }
1274
+
1275
+ function StopIcon() {
1276
+ return (
1277
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1278
+ <rect x="6" y="6" width="12" height="12" />
1279
+ </svg>
1280
+ );
1281
+ }
1282
+
1283
+ function RestartIcon() {
1284
+ return (
1285
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1286
+ <path d="M23 4v6h-6" />
1287
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
1288
+ </svg>
1289
+ );
1290
+ }
1291
+
1292
+ function CheckIcon() {
1293
+ return (
1294
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1295
+ <polyline points="20 6 9 17 4 12" />
1296
+ </svg>
1297
+ );
1298
+ }
1299
+
1300
+ function TrashIcon() {
1301
+ return (
1302
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1303
+ <polyline points="3 6 5 6 21 6" />
1304
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
1305
+ </svg>
1306
+ );
1307
+ }
1308
+
1309
+ function CloseIcon() {
1310
+ return (
1311
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1312
+ <line x1="18" y1="6" x2="6" y2="18" />
1313
+ <line x1="6" y1="6" x2="18" y2="18" />
1314
+ </svg>
1315
+ );
1316
+ }
1317
+
1318
+ function InfoIcon() {
1319
+ return (
1320
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1321
+ <circle cx="12" cy="12" r="10" />
1322
+ <line x1="12" y1="16" x2="12" y2="12" />
1323
+ <line x1="12" y1="8" x2="12.01" y2="8" />
1324
+ </svg>
1325
+ );
1326
+ }
1327
+
1328
+ function SpinnerIcon() {
1329
+ return (
1330
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-spin">
1331
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
1332
+ </svg>
1333
+ );
1334
+ }
1335
+
1336
+ function RebuildIcon() {
1337
+ return (
1338
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1339
+ <path d="M2 12a10 10 0 0 1 10-10" />
1340
+ <path d="M22 12a10 10 0 0 1-10 10" />
1341
+ <path d="M12 2v4" />
1342
+ <path d="M12 18v4" />
1343
+ <path d="M4.93 4.93l2.83 2.83" />
1344
+ <path d="M16.24 16.24l2.83 2.83" />
1345
+ </svg>
1346
+ );
1347
+ }
1348
+
1349
+ function SyncIcon({ spinning = false }: { spinning?: boolean } = {}) {
1350
+ return (
1351
+ <svg
1352
+ width="12"
1353
+ height="12"
1354
+ viewBox="0 0 24 24"
1355
+ fill="none"
1356
+ stroke="currentColor"
1357
+ strokeWidth="2"
1358
+ strokeLinecap="round"
1359
+ strokeLinejoin="round"
1360
+ className={spinning ? 'animate-spin' : ''}
1361
+ >
1362
+ <path d="M23 4v6h-6" />
1363
+ <path d="M1 20v-6h6" />
1364
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
1365
+ <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
1366
+ </svg>
1367
+ );
1368
+ }