@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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /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
|
+
}
|