@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,901 @@
1
+ /**
2
+ * RepositoriesPanel - Unified Repository Management
3
+ *
4
+ * Consolidated view of all repositories the user has access to.
5
+ * Shows GitHub App connected repos at top, then all accessible repos.
6
+ *
7
+ * Button logic:
8
+ * - If repo is already in workspace → "Connected"
9
+ * - If GitHub App has access BUT not in workspace → "Add to Workspace"
10
+ * - If GitHub App does NOT have access → "Enable Access" (triggers reconnect flow)
11
+ *
12
+ * Uses:
13
+ * - GET /api/repos/accessible - List repos user can access via GitHub OAuth
14
+ * - GET /api/repos/check-github-app-access/:owner/:repo - Check GitHub App access
15
+ * - POST /api/workspaces/:id/repos - Add repo to workspace
16
+ */
17
+
18
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
19
+ import Nango from '@nangohq/frontend';
20
+
21
+ interface GitHubAppAccessResult {
22
+ hasAccess: boolean;
23
+ needsReconnect: boolean;
24
+ reason?: string;
25
+ message?: string;
26
+ connectionId?: string;
27
+ source?: 'own' | 'shared';
28
+ }
29
+
30
+ interface AccessibleRepo {
31
+ id: number;
32
+ fullName: string;
33
+ isPrivate: boolean;
34
+ defaultBranch: string;
35
+ permissions: {
36
+ admin: boolean;
37
+ push: boolean;
38
+ pull: boolean;
39
+ };
40
+ }
41
+
42
+ interface WorkspaceRepo {
43
+ id: string;
44
+ fullName: string;
45
+ syncStatus: string;
46
+ }
47
+
48
+ export interface RepositoriesPanelProps {
49
+ /** Current workspace ID to add repos to */
50
+ workspaceId: string;
51
+ /** Repos already in the workspace */
52
+ workspaceRepos?: WorkspaceRepo[];
53
+ /** Callback when a repo is added to the workspace */
54
+ onRepoAdded?: (repoFullName: string) => void;
55
+ /** Callback when a repo is removed from the workspace */
56
+ onRepoRemoved?: (repoFullName: string) => void;
57
+ /** CSRF token for mutations */
58
+ csrfToken?: string;
59
+ /** Custom class name */
60
+ className?: string;
61
+ }
62
+
63
+ type LoadingState = 'idle' | 'loading' | 'loaded' | 'error';
64
+
65
+ interface RepoWithStatus extends AccessibleRepo {
66
+ gitHubAppAccess: 'unknown' | 'checking' | 'has_access' | 'no_access';
67
+ isInWorkspace: boolean;
68
+ }
69
+
70
+ function getPermissionLevel(permissions: { admin: boolean; push: boolean; pull: boolean }): {
71
+ level: 'admin' | 'write' | 'read';
72
+ label: string;
73
+ color: string;
74
+ } {
75
+ if (permissions.admin) {
76
+ return { level: 'admin', label: 'Admin', color: 'text-accent-purple bg-accent-purple/10 border-accent-purple/30' };
77
+ }
78
+ if (permissions.push) {
79
+ return { level: 'write', label: 'Write', color: 'text-accent-cyan bg-accent-cyan/10 border-accent-cyan/30' };
80
+ }
81
+ return { level: 'read', label: 'Read', color: 'text-text-muted bg-bg-tertiary border-border-subtle' };
82
+ }
83
+
84
+ // Icons
85
+ const GitHubIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
86
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor">
87
+ <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" />
88
+ </svg>
89
+ );
90
+
91
+ const SearchIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
92
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
94
+ </svg>
95
+ );
96
+
97
+ const LockIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
98
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
99
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
100
+ </svg>
101
+ );
102
+
103
+ const CheckIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
104
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
105
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
106
+ </svg>
107
+ );
108
+
109
+ const REPOS_PER_PAGE = 25;
110
+
111
+ interface SearchResult {
112
+ id: number;
113
+ fullName: string;
114
+ isPrivate: boolean;
115
+ defaultBranch: string;
116
+ description?: string;
117
+ permissions: {
118
+ admin: boolean;
119
+ push: boolean;
120
+ pull: boolean;
121
+ };
122
+ }
123
+
124
+ export function RepositoriesPanel({
125
+ workspaceId,
126
+ workspaceRepos = [],
127
+ onRepoAdded,
128
+ onRepoRemoved,
129
+ csrfToken,
130
+ className = '',
131
+ }: RepositoriesPanelProps) {
132
+ const [repos, setRepos] = useState<RepoWithStatus[]>([]);
133
+ const [loadingState, setLoadingState] = useState<LoadingState>('idle');
134
+ const [error, setError] = useState<string | null>(null);
135
+ const [isGitHubNotConnected, setIsGitHubNotConnected] = useState(false);
136
+ const [searchQuery, setSearchQuery] = useState('');
137
+ const [currentPage, setCurrentPage] = useState(1);
138
+ const [hasMore, setHasMore] = useState(false);
139
+ const [loadingMore, setLoadingMore] = useState(false);
140
+
141
+ // Search state
142
+ const [isSearching, setIsSearching] = useState(false);
143
+ const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
144
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
145
+
146
+ // Action states
147
+ const [addingRepo, setAddingRepo] = useState<string | null>(null);
148
+ const [removingRepo, setRemovingRepo] = useState<string | null>(null);
149
+ const [checkingAccess, setCheckingAccess] = useState<Set<string>>(new Set());
150
+
151
+ // GitHub OAuth state (for initial connection when not connected)
152
+ const nangoRef = useRef<InstanceType<typeof Nango> | null>(null);
153
+ const [isNangoReady, setIsNangoReady] = useState(false);
154
+ const [isConnecting, setIsConnecting] = useState(false);
155
+ const [connectError, setConnectError] = useState<string | null>(null);
156
+
157
+ // GitHub App reconnect state
158
+ const [isReconnecting, setIsReconnecting] = useState(false);
159
+ const [pendingRepoForAdd, setPendingRepoForAdd] = useState<string | null>(null);
160
+ const [reconnectSuccessful, setReconnectSuccessful] = useState(false);
161
+
162
+ // GitHub App accessible repos (fetched once on load)
163
+ const [githubAppRepos, setGithubAppRepos] = useState<Set<string>>(new Set());
164
+ const [hasGitHubAppConnection, setHasGitHubAppConnection] = useState(false);
165
+
166
+ // Build a set of repos already in workspace (memoized to prevent re-renders)
167
+ const workspaceRepoSet = React.useMemo(
168
+ () => new Set(workspaceRepos.map(r => r.fullName.toLowerCase())),
169
+ [workspaceRepos]
170
+ );
171
+
172
+ // Fetch accessible repos
173
+ const fetchRepos = useCallback(async (page = 1, append = false) => {
174
+ if (!append) {
175
+ setLoadingState('loading');
176
+ }
177
+ setError(null);
178
+
179
+ try {
180
+ const response = await fetch(`/api/repos/accessible?perPage=${REPOS_PER_PAGE}&page=${page}`, {
181
+ credentials: 'include',
182
+ });
183
+ const data = await response.json();
184
+
185
+ if (!response.ok) {
186
+ if (data.code === 'NANGO_NOT_CONNECTED') {
187
+ setIsGitHubNotConnected(true);
188
+ }
189
+ throw new Error(data.error || 'Failed to fetch repositories');
190
+ }
191
+
192
+ const accessibleRepos: AccessibleRepo[] = data.repositories || [];
193
+
194
+ // Convert to RepoWithStatus (isInWorkspace will be computed from workspaceRepoSet)
195
+ const reposWithStatus: RepoWithStatus[] = accessibleRepos.map(repo => ({
196
+ ...repo,
197
+ gitHubAppAccess: 'unknown' as const,
198
+ isInWorkspace: false, // Will be computed in render based on workspaceRepoSet
199
+ }));
200
+
201
+ if (append) {
202
+ setRepos(prev => [...prev, ...reposWithStatus]);
203
+ } else {
204
+ setRepos(reposWithStatus);
205
+ }
206
+ setCurrentPage(page);
207
+ setHasMore(data.pagination?.hasMore || false);
208
+ setLoadingState('loaded');
209
+ setIsGitHubNotConnected(false);
210
+ } catch (err) {
211
+ console.error('Error fetching repos:', err);
212
+ setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
213
+ setLoadingState('error');
214
+ }
215
+ }, []);
216
+
217
+ // Fetch GitHub App accessible repos (to know which repos can be added directly)
218
+ const fetchGitHubAppRepos = useCallback(async () => {
219
+ try {
220
+ const response = await fetch('/api/repos/github-app-accessible', {
221
+ credentials: 'include',
222
+ });
223
+ const data = await response.json();
224
+
225
+ if (response.ok && data.repositories) {
226
+ const repoNames: string[] = data.repositories.map(
227
+ (r: { fullName: string }) => r.fullName.toLowerCase()
228
+ );
229
+ setGithubAppRepos(new Set(repoNames));
230
+ setHasGitHubAppConnection(data.hasConnection || false);
231
+ }
232
+ } catch (err) {
233
+ console.error('Error fetching GitHub App repos:', err);
234
+ }
235
+ }, []);
236
+
237
+ // Initial fetch - get both user repos and GitHub App repos
238
+ useEffect(() => {
239
+ fetchRepos();
240
+ fetchGitHubAppRepos();
241
+ }, [fetchRepos, fetchGitHubAppRepos]);
242
+
243
+ // Initialize Nango when GitHub is not connected
244
+ useEffect(() => {
245
+ if (!isGitHubNotConnected) return;
246
+
247
+ let mounted = true;
248
+
249
+ const initNango = async () => {
250
+ try {
251
+ const response = await fetch('/api/auth/nango/login-session', {
252
+ credentials: 'include',
253
+ });
254
+ const data = await response.json();
255
+
256
+ if (!mounted) return;
257
+
258
+ if (response.ok && data.sessionToken) {
259
+ nangoRef.current = new Nango({ connectSessionToken: data.sessionToken });
260
+ setIsNangoReady(true);
261
+ }
262
+ } catch (err) {
263
+ console.error('Failed to initialize Nango:', err);
264
+ }
265
+ };
266
+
267
+ initNango();
268
+ return () => { mounted = false; };
269
+ }, [isGitHubNotConnected]);
270
+
271
+ // Check GitHub App access for a specific repo
272
+ const checkGitHubAppAccess = useCallback(async (repoFullName: string): Promise<GitHubAppAccessResult> => {
273
+ const [owner, repo] = repoFullName.split('/');
274
+ if (!owner || !repo) {
275
+ return { hasAccess: false, needsReconnect: false, message: 'Invalid repository name' };
276
+ }
277
+
278
+ try {
279
+ const response = await fetch(`/api/repos/check-github-app-access/${owner}/${repo}`, {
280
+ credentials: 'include',
281
+ });
282
+ const data = await response.json();
283
+
284
+ if (!response.ok) {
285
+ return { hasAccess: false, needsReconnect: true, message: data.error || 'Failed to check access' };
286
+ }
287
+
288
+ return data as GitHubAppAccessResult;
289
+ } catch (err) {
290
+ console.error('Error checking GitHub App access:', err);
291
+ return { hasAccess: false, needsReconnect: true, message: 'Failed to check access' };
292
+ }
293
+ }, []);
294
+
295
+ // Update repo's GitHub App access status
296
+ const updateRepoAccessStatus = useCallback((repoFullName: string, status: RepoWithStatus['gitHubAppAccess']) => {
297
+ setRepos(prev => prev.map(repo =>
298
+ repo.fullName === repoFullName ? { ...repo, gitHubAppAccess: status } : repo
299
+ ));
300
+ }, []);
301
+
302
+ // Handle GitHub App reconnect to add a repo
303
+ const handleReconnectGitHubApp = useCallback(async (repoFullName: string) => {
304
+ setIsReconnecting(true);
305
+ setPendingRepoForAdd(repoFullName);
306
+ setReconnectSuccessful(false);
307
+ setError(null);
308
+
309
+ try {
310
+ // First, try to get a reconnect session (for existing connections)
311
+ let sessionResponse = await fetch('/api/auth/nango/repo-reconnect-session', {
312
+ credentials: 'include',
313
+ });
314
+ let sessionData = await sessionResponse.json();
315
+
316
+ // If no existing connection, fall back to regular connect flow
317
+ if (!sessionResponse.ok || sessionData.code === 'NO_EXISTING_CONNECTION') {
318
+ sessionResponse = await fetch('/api/auth/nango/repo-session', {
319
+ credentials: 'include',
320
+ });
321
+ sessionData = await sessionResponse.json();
322
+ }
323
+
324
+ if (!sessionResponse.ok || !sessionData.sessionToken) {
325
+ setError('Failed to initialize GitHub connection. Please refresh the page.');
326
+ setIsReconnecting(false);
327
+ setPendingRepoForAdd(null);
328
+ return;
329
+ }
330
+
331
+ // Create Nango instance with the session token
332
+ const nangoInstance = new Nango({ connectSessionToken: sessionData.sessionToken });
333
+
334
+ // Open the GitHub App installation popup (fire-and-forget).
335
+ // The popup may not close automatically for GitHub App OAuth flows,
336
+ // so we don't await the result. Instead, poll for repo access directly.
337
+ nangoInstance.auth('github-app-oauth').catch((err: unknown) => {
338
+ const authErr = err as Error & { type?: string };
339
+ // Only log non-cancellation errors; user closing popup is expected
340
+ if (authErr.type !== 'user_cancelled' && !authErr.message?.includes('closed')) {
341
+ console.error('GitHub App auth background error:', authErr);
342
+ }
343
+ });
344
+
345
+ // Poll check-github-app-access for the specific repo being added.
346
+ // The webhook will sync the repo to the DB, and this endpoint checks
347
+ // whether the GitHub App installation now includes the target repo.
348
+ const [owner, repo] = repoFullName.split('/');
349
+ const pollForAccess = async (attempts = 0): Promise<boolean> => {
350
+ if (attempts > 60) {
351
+ throw new Error('Connection timed out. Please try again.');
352
+ }
353
+
354
+ try {
355
+ const accessRes = await fetch(`/api/repos/check-github-app-access/${owner}/${repo}`, {
356
+ credentials: 'include',
357
+ });
358
+ const accessData = await accessRes.json();
359
+
360
+ if (accessData.hasAccess) {
361
+ return true;
362
+ }
363
+ } catch {
364
+ // Network error - continue polling
365
+ }
366
+
367
+ await new Promise(resolve => setTimeout(resolve, 2000));
368
+ return pollForAccess(attempts + 1);
369
+ };
370
+
371
+ const success = await pollForAccess();
372
+ if (success) {
373
+ setReconnectSuccessful(true);
374
+ setIsReconnecting(false);
375
+ }
376
+ } catch (err: unknown) {
377
+ const error = err as Error & { type?: string };
378
+ console.error('GitHub App reconnect error:', error);
379
+ setError(error.message || 'Failed to reconnect GitHub');
380
+ setPendingRepoForAdd(null);
381
+ setIsReconnecting(false);
382
+ }
383
+ }, []);
384
+
385
+ // Effect to add repo after successful reconnect
386
+ useEffect(() => {
387
+ if (reconnectSuccessful && pendingRepoForAdd && !isReconnecting) {
388
+ const repoName = pendingRepoForAdd;
389
+ setReconnectSuccessful(false);
390
+ setPendingRepoForAdd(null);
391
+ // Update repo status and try to add to workspace
392
+ updateRepoAccessStatus(repoName, 'has_access');
393
+ handleAddToWorkspace(repoName);
394
+ }
395
+ }, [reconnectSuccessful, pendingRepoForAdd, isReconnecting]);
396
+
397
+ // Handle adding repo to workspace
398
+ const handleAddToWorkspace = useCallback(async (repoFullName: string) => {
399
+ setAddingRepo(repoFullName);
400
+ setError(null);
401
+
402
+ try {
403
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
404
+ if (csrfToken) {
405
+ headers['X-CSRF-Token'] = csrfToken;
406
+ }
407
+
408
+ const response = await fetch(`/api/workspaces/${workspaceId}/repos`, {
409
+ method: 'POST',
410
+ credentials: 'include',
411
+ headers,
412
+ body: JSON.stringify({ repositoryFullName: repoFullName }),
413
+ });
414
+
415
+ const data = await response.json();
416
+
417
+ if (!response.ok) {
418
+ throw new Error(data.error || 'Failed to add repository');
419
+ }
420
+
421
+ // Callback will trigger parent to refresh workspaceRepos, which updates workspaceRepoSet
422
+ onRepoAdded?.(repoFullName);
423
+ } catch (err) {
424
+ console.error('Error adding repo to workspace:', err);
425
+ setError(err instanceof Error ? err.message : 'Failed to add repository');
426
+ } finally {
427
+ setAddingRepo(null);
428
+ }
429
+ }, [workspaceId, csrfToken, onRepoAdded]);
430
+
431
+ // Remove repo from workspace
432
+ const handleRemoveRepo = useCallback(async (repo: RepoWithStatus) => {
433
+ // Find the workspace repo record to get its DB id
434
+ const wsRepo = workspaceRepos.find(
435
+ r => r.fullName.toLowerCase() === repo.fullName.toLowerCase()
436
+ );
437
+ if (!wsRepo) return;
438
+
439
+ setRemovingRepo(repo.fullName);
440
+ setError(null);
441
+
442
+ try {
443
+ const headers: Record<string, string> = {};
444
+ if (csrfToken) {
445
+ headers['X-CSRF-Token'] = csrfToken;
446
+ }
447
+
448
+ const response = await fetch(`/api/workspaces/${workspaceId}/repos/${wsRepo.id}`, {
449
+ method: 'DELETE',
450
+ credentials: 'include',
451
+ headers,
452
+ });
453
+
454
+ const data = await response.json();
455
+
456
+ if (!response.ok) {
457
+ throw new Error(data.error || 'Failed to remove repository');
458
+ }
459
+
460
+ onRepoRemoved?.(repo.fullName);
461
+ } catch (err) {
462
+ console.error('Error removing repo from workspace:', err);
463
+ setError(err instanceof Error ? err.message : 'Failed to remove repository');
464
+ } finally {
465
+ setRemovingRepo(null);
466
+ }
467
+ }, [workspaceId, workspaceRepos, csrfToken, onRepoRemoved]);
468
+
469
+ // Handle button click - check access and either add or reconnect
470
+ const handleRepoAction = useCallback(async (repo: RepoWithStatus) => {
471
+ if (repo.isInWorkspace) return; // Already connected
472
+
473
+ // If we already know it has access, add directly
474
+ if (repo.gitHubAppAccess === 'has_access') {
475
+ await handleAddToWorkspace(repo.fullName);
476
+ return;
477
+ }
478
+
479
+ // Check GitHub App access first
480
+ setCheckingAccess(prev => new Set(prev).add(repo.fullName));
481
+ updateRepoAccessStatus(repo.fullName, 'checking');
482
+
483
+ const accessResult = await checkGitHubAppAccess(repo.fullName);
484
+
485
+ setCheckingAccess(prev => {
486
+ const next = new Set(prev);
487
+ next.delete(repo.fullName);
488
+ return next;
489
+ });
490
+
491
+ if (accessResult.hasAccess) {
492
+ updateRepoAccessStatus(repo.fullName, 'has_access');
493
+ await handleAddToWorkspace(repo.fullName);
494
+ } else {
495
+ updateRepoAccessStatus(repo.fullName, 'no_access');
496
+ // Trigger reconnect flow
497
+ await handleReconnectGitHubApp(repo.fullName);
498
+ }
499
+ }, [checkGitHubAppAccess, handleAddToWorkspace, handleReconnectGitHubApp, updateRepoAccessStatus]);
500
+
501
+ // Handle GitHub OAuth connection
502
+ const handleConnectGitHub = async () => {
503
+ if (!nangoRef.current) {
504
+ setConnectError('GitHub connection not available. Please refresh the page.');
505
+ return;
506
+ }
507
+
508
+ setIsConnecting(true);
509
+ setConnectError(null);
510
+
511
+ try {
512
+ await nangoRef.current.auth('github');
513
+ // Reload the page to refresh auth state
514
+ window.location.reload();
515
+ } catch (err: unknown) {
516
+ const error = err as Error & { type?: string };
517
+ if (error.type !== 'user_cancelled') {
518
+ setConnectError(error.message || 'Failed to connect GitHub');
519
+ }
520
+ } finally {
521
+ setIsConnecting(false);
522
+ }
523
+ };
524
+
525
+ // Load more repos from server
526
+ const handleLoadMore = async () => {
527
+ if (loadingMore || !hasMore) return;
528
+ setLoadingMore(true);
529
+ try {
530
+ await fetchRepos(currentPage + 1, true);
531
+ } finally {
532
+ setLoadingMore(false);
533
+ }
534
+ };
535
+
536
+ // Search repos via GitHub API
537
+ const handleSearch = useCallback(async (query: string) => {
538
+ if (!query.trim()) {
539
+ setSearchResults([]);
540
+ setIsSearching(false);
541
+ return;
542
+ }
543
+
544
+ setIsSearching(true);
545
+ try {
546
+ const response = await fetch(`/api/repos/search?q=${encodeURIComponent(query)}`, {
547
+ credentials: 'include',
548
+ });
549
+ const data = await response.json();
550
+
551
+ if (response.ok) {
552
+ // Map search results to match our format
553
+ const results: SearchResult[] = (data.repositories || []).map((r: {
554
+ githubId: number;
555
+ fullName: string;
556
+ isPrivate: boolean;
557
+ defaultBranch: string;
558
+ description?: string;
559
+ }) => ({
560
+ id: r.githubId,
561
+ fullName: r.fullName,
562
+ isPrivate: r.isPrivate,
563
+ defaultBranch: r.defaultBranch,
564
+ description: r.description,
565
+ permissions: { admin: false, push: true, pull: true }, // Assume write access since they found it
566
+ }));
567
+ setSearchResults(results);
568
+ }
569
+ } catch (err) {
570
+ console.error('Search error:', err);
571
+ } finally {
572
+ setIsSearching(false);
573
+ }
574
+ }, []);
575
+
576
+ // Debounced search
577
+ const handleSearchChange = useCallback((value: string) => {
578
+ setSearchQuery(value);
579
+
580
+ if (searchTimeoutRef.current) {
581
+ clearTimeout(searchTimeoutRef.current);
582
+ }
583
+
584
+ if (value.trim()) {
585
+ searchTimeoutRef.current = setTimeout(() => {
586
+ handleSearch(value);
587
+ }, 300);
588
+ } else {
589
+ setSearchResults([]);
590
+ }
591
+ }, [handleSearch]);
592
+
593
+ // When searching, use search results; otherwise use fetched repos
594
+ const displayRepos = React.useMemo(() => {
595
+ // Helper to determine GitHub App access status
596
+ const getGitHubAppAccess = (fullName: string): RepoWithStatus['gitHubAppAccess'] => {
597
+ if (githubAppRepos.has(fullName.toLowerCase())) {
598
+ return 'has_access';
599
+ }
600
+ return hasGitHubAppConnection ? 'no_access' : 'unknown';
601
+ };
602
+
603
+ if (searchQuery.trim() && searchResults.length > 0) {
604
+ // Convert search results to RepoWithStatus format
605
+ return searchResults.map(r => ({
606
+ ...r,
607
+ gitHubAppAccess: getGitHubAppAccess(r.fullName),
608
+ isInWorkspace: workspaceRepoSet.has(r.fullName.toLowerCase()),
609
+ }));
610
+ }
611
+
612
+ // No search - add isInWorkspace and gitHubAppAccess
613
+ return repos.map(repo => ({
614
+ ...repo,
615
+ gitHubAppAccess: getGitHubAppAccess(repo.fullName),
616
+ isInWorkspace: workspaceRepoSet.has(repo.fullName.toLowerCase()),
617
+ }));
618
+ }, [repos, searchResults, searchQuery, workspaceRepoSet, githubAppRepos, hasGitHubAppConnection]);
619
+
620
+ // Split repos into workspace repos and other repos
621
+ const inWorkspaceRepos = displayRepos.filter(r => r.isInWorkspace);
622
+
623
+ // Sort available repos: GitHub App accessible first, then others
624
+ const availableRepos = displayRepos
625
+ .filter(r => !r.isInWorkspace)
626
+ .sort((a, b) => {
627
+ // GitHub App access repos first
628
+ if (a.gitHubAppAccess === 'has_access' && b.gitHubAppAccess !== 'has_access') return -1;
629
+ if (a.gitHubAppAccess !== 'has_access' && b.gitHubAppAccess === 'has_access') return 1;
630
+ return 0;
631
+ });
632
+
633
+ // Show load more only when not searching
634
+ const showLoadMore = !searchQuery.trim() && hasMore;
635
+
636
+ // Loading state
637
+ if (loadingState === 'loading') {
638
+ return (
639
+ <div className={`flex items-center justify-center py-12 ${className}`}>
640
+ <div className="text-center">
641
+ <svg className="w-8 h-8 text-accent-cyan animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
642
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
643
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
644
+ </svg>
645
+ <p className="mt-4 text-text-muted">Loading repositories...</p>
646
+ </div>
647
+ </div>
648
+ );
649
+ }
650
+
651
+ // Error state - GitHub not connected
652
+ if (loadingState === 'error' && isGitHubNotConnected) {
653
+ return (
654
+ <div className={`p-6 ${className}`}>
655
+ <div className="bg-bg-tertiary border border-border-subtle rounded-xl p-8 text-center">
656
+ <div className="w-16 h-16 mx-auto mb-4 bg-bg-hover rounded-full flex items-center justify-center">
657
+ <GitHubIcon className="w-8 h-8 text-text-muted" />
658
+ </div>
659
+ <h3 className="text-lg font-semibold text-text-primary mb-2">Connect GitHub</h3>
660
+ <p className="text-text-muted mb-6 max-w-md mx-auto">
661
+ Connect your GitHub account to see your repositories and enable agent access to your code.
662
+ </p>
663
+ {connectError && (
664
+ <p className="text-error text-sm mb-4">{connectError}</p>
665
+ )}
666
+ <button
667
+ onClick={handleConnectGitHub}
668
+ disabled={!isNangoReady || isConnecting}
669
+ className="px-6 py-3 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-medium rounded-lg hover:shadow-glow-cyan transition-all disabled:opacity-50 disabled:cursor-not-allowed"
670
+ >
671
+ {isConnecting ? 'Connecting...' : !isNangoReady ? 'Loading...' : (
672
+ <span className="flex items-center gap-2">
673
+ <GitHubIcon className="w-5 h-5" />
674
+ Connect GitHub Account
675
+ </span>
676
+ )}
677
+ </button>
678
+ </div>
679
+ </div>
680
+ );
681
+ }
682
+
683
+ // General error state
684
+ if (loadingState === 'error') {
685
+ return (
686
+ <div className={`p-6 ${className}`}>
687
+ <div className="bg-error/10 border border-error/20 rounded-xl p-4 text-center">
688
+ <p className="text-error mb-4">{error}</p>
689
+ <button
690
+ onClick={() => fetchRepos()}
691
+ className="px-4 py-2 bg-bg-tertiary border border-border-subtle rounded-lg text-text-primary hover:bg-bg-hover transition-colors"
692
+ >
693
+ Try Again
694
+ </button>
695
+ </div>
696
+ </div>
697
+ );
698
+ }
699
+
700
+ const renderRepoItem = (repo: RepoWithStatus) => {
701
+ const permission = getPermissionLevel(repo.permissions);
702
+ const isAdding = addingRepo === repo.fullName;
703
+ const isChecking = checkingAccess.has(repo.fullName) || repo.gitHubAppAccess === 'checking';
704
+ const isReconnectingThis = isReconnecting && pendingRepoForAdd === repo.fullName;
705
+
706
+ return (
707
+ <div
708
+ key={repo.id}
709
+ className="flex items-center justify-between p-4 hover:bg-bg-hover transition-colors"
710
+ >
711
+ {/* Repo info */}
712
+ <div className="flex items-center gap-3 min-w-0 flex-1">
713
+ <GitHubIcon className="w-5 h-5 text-text-muted flex-shrink-0" />
714
+ <div className="min-w-0">
715
+ <div className="flex items-center gap-2">
716
+ <span className="font-medium text-text-primary truncate">{repo.fullName}</span>
717
+ {repo.isPrivate && (
718
+ <LockIcon className="w-3.5 h-3.5 text-text-muted flex-shrink-0" />
719
+ )}
720
+ </div>
721
+ <div className="flex items-center gap-2 mt-0.5">
722
+ <span className={`text-xs px-1.5 py-0.5 rounded border ${permission.color}`}>
723
+ {permission.label}
724
+ </span>
725
+ <span className="text-xs text-text-muted">{repo.defaultBranch}</span>
726
+ </div>
727
+ </div>
728
+ </div>
729
+
730
+ {/* Action button */}
731
+ <div className="flex-shrink-0 ml-4">
732
+ {repo.isInWorkspace ? (
733
+ <div className="flex items-center gap-2">
734
+ <span className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-success bg-success/10 border border-success/30 rounded-lg">
735
+ <CheckIcon className="w-4 h-4" />
736
+ In Workspace
737
+ </span>
738
+ <button
739
+ onClick={() => handleRemoveRepo(repo)}
740
+ disabled={removingRepo === repo.fullName}
741
+ className="p-1.5 text-text-muted hover:text-error hover:bg-error/10 rounded-md transition-colors disabled:opacity-50"
742
+ title="Remove from workspace"
743
+ >
744
+ {removingRepo === repo.fullName ? (
745
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
746
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
747
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
748
+ </svg>
749
+ ) : (
750
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
751
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
752
+ </svg>
753
+ )}
754
+ </button>
755
+ </div>
756
+ ) : (
757
+ <button
758
+ onClick={() => handleRepoAction(repo)}
759
+ disabled={isAdding || isChecking || isReconnectingThis}
760
+ className="px-4 py-2 text-sm bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-medium rounded-lg hover:shadow-glow-cyan transition-all disabled:opacity-50 disabled:cursor-not-allowed"
761
+ >
762
+ {isChecking ? (
763
+ <span className="flex items-center gap-2">
764
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
765
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
766
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
767
+ </svg>
768
+ Checking...
769
+ </span>
770
+ ) : isReconnectingThis ? (
771
+ <span className="flex items-center gap-2">
772
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
773
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
774
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
775
+ </svg>
776
+ Connecting...
777
+ </span>
778
+ ) : isAdding ? (
779
+ <span className="flex items-center gap-2">
780
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
781
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
782
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
783
+ </svg>
784
+ Adding...
785
+ </span>
786
+ ) : repo.gitHubAppAccess === 'has_access' ? (
787
+ 'Add to Workspace'
788
+ ) : repo.gitHubAppAccess === 'no_access' ? (
789
+ 'Enable Access'
790
+ ) : (
791
+ 'Add'
792
+ )}
793
+ </button>
794
+ )}
795
+ </div>
796
+ </div>
797
+ );
798
+ };
799
+
800
+ return (
801
+ <div className={className}>
802
+ {/* Error banner */}
803
+ {error && (
804
+ <div className="mx-4 mt-4 p-3 bg-error/10 border border-error/20 rounded-lg">
805
+ <p className="text-error text-sm">{error}</p>
806
+ </div>
807
+ )}
808
+
809
+ {/* Search */}
810
+ <div className="p-4 border-b border-border-subtle">
811
+ <div className="relative">
812
+ <SearchIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
813
+ <input
814
+ type="text"
815
+ placeholder="Search your GitHub repositories..."
816
+ value={searchQuery}
817
+ onChange={(e) => handleSearchChange(e.target.value)}
818
+ className="w-full pl-10 pr-4 py-2 bg-bg-tertiary border border-border-subtle rounded-lg text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-cyan/50 transition-colors"
819
+ />
820
+ {isSearching && (
821
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
822
+ <svg className="w-4 h-4 text-text-muted animate-spin" fill="none" viewBox="0 0 24 24">
823
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
824
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
825
+ </svg>
826
+ </div>
827
+ )}
828
+ </div>
829
+ {searchQuery.trim() && (
830
+ <p className="mt-2 text-xs text-text-muted">
831
+ {isSearching ? 'Searching...' : searchResults.length > 0 ? `Found ${searchResults.length} results` : 'No results found. Try a different search term.'}
832
+ </p>
833
+ )}
834
+ </div>
835
+
836
+ {/* Repos in this workspace */}
837
+ {inWorkspaceRepos.length > 0 && (
838
+ <div className="border-b border-border-subtle">
839
+ <div className="px-4 py-3 bg-bg-tertiary/50">
840
+ <h3 className="text-sm font-semibold text-text-primary">
841
+ In This Workspace ({inWorkspaceRepos.length})
842
+ </h3>
843
+ <p className="text-xs text-text-muted mt-0.5">
844
+ Repositories already added to this workspace
845
+ </p>
846
+ </div>
847
+ <div className="divide-y divide-border-subtle">
848
+ {inWorkspaceRepos.map(renderRepoItem)}
849
+ </div>
850
+ </div>
851
+ )}
852
+
853
+ {/* Available repos section */}
854
+ <div>
855
+ <div className="px-4 py-3 bg-bg-tertiary/50 border-b border-border-subtle">
856
+ <h3 className="text-sm font-semibold text-text-primary">
857
+ {searchQuery.trim() ? 'Search Results' : 'Available Repositories'} ({availableRepos.length})
858
+ </h3>
859
+ <p className="text-xs text-text-muted mt-0.5">
860
+ {searchQuery.trim() ? 'Matching repositories from GitHub' : 'Repositories you have access to on GitHub'}
861
+ </p>
862
+ </div>
863
+
864
+ {availableRepos.length > 0 ? (
865
+ <>
866
+ <div className="divide-y divide-border-subtle">
867
+ {availableRepos.map(renderRepoItem)}
868
+ </div>
869
+
870
+ {/* Load more button - only when not searching */}
871
+ {showLoadMore && (
872
+ <div className="p-4 border-t border-border-subtle">
873
+ <button
874
+ onClick={handleLoadMore}
875
+ disabled={loadingMore}
876
+ className="w-full py-2 text-sm text-accent-cyan hover:text-accent-cyan/80 transition-colors disabled:opacity-50"
877
+ >
878
+ {loadingMore ? (
879
+ <span className="flex items-center justify-center gap-2">
880
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
881
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
882
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
883
+ </svg>
884
+ Loading more...
885
+ </span>
886
+ ) : 'Load More Repositories'}
887
+ </button>
888
+ </div>
889
+ )}
890
+ </>
891
+ ) : (
892
+ <div className="p-8 text-center">
893
+ <p className="text-text-muted">
894
+ {searchQuery.trim() ? 'No repositories match your search' : 'No additional repositories available'}
895
+ </p>
896
+ </div>
897
+ )}
898
+ </div>
899
+ </div>
900
+ );
901
+ }