diff --git a/apps/storybook/.storybook/preview-head.html b/apps/storybook/.storybook/preview-head.html new file mode 100644 index 0000000000..a284c4fc89 --- /dev/null +++ b/apps/storybook/.storybook/preview-head.html @@ -0,0 +1,15 @@ + diff --git a/apps/storybook/stories/cloud-agent/RepositoryProviderOnboardingPanel.stories.tsx b/apps/storybook/stories/cloud-agent/RepositoryProviderOnboardingPanel.stories.tsx new file mode 100644 index 0000000000..d9ede55b94 --- /dev/null +++ b/apps/storybook/stories/cloud-agent/RepositoryProviderOnboardingPanel.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { RepositoryProviderOnboardingPanel } from '@/components/cloud-agent-next/RepositoryProviderOnboardingPanel'; + +const meta: Meta = { + title: 'Cloud Agent/RepositoryProviderOnboardingPanel', + component: RepositoryProviderOnboardingPanel, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + onCheckConnection: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Personal: Story = {}; + +export const Organization: Story = { + args: { + organizationId: '8e65c211-a284-4a57-a077-16db90751abc', + }, +}; + +export const CheckingConnection: Story = { + args: { + isCheckingConnection: true, + }, +}; diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index 953e8ddf0d..47fdcc3f6f 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -60,7 +60,6 @@ import { CommandList, } from '@/components/ui/command'; import { Button as UIButton } from '@/components/ui/button'; -import { LinkButton } from '@/components/Button'; import { cn } from '@/lib/utils'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; @@ -104,6 +103,7 @@ import { shouldCacheBitbucketRepositoryRefreshResult, shouldIncludeBitbucketRepositoryRefresh, } from '@/components/cloud-agent-next/repository-refresh'; +import { RepositoryProviderOnboardingPanel } from './RepositoryProviderOnboardingPanel'; type Repository = { id: string | number; @@ -426,6 +426,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New data: githubRepoData, isLoading: isLoadingGitHubRepos, error: githubRepoError, + refetch: refetchGitHubRepositories, } = useQuery( organizationId ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ @@ -441,6 +442,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New data: gitlabRepoData, isLoading: isLoadingGitLabRepos, error: gitlabRepoError, + refetch: refetchGitLabRepositories, } = useQuery( organizationId ? trpc.organizations.cloudAgentNext.listGitLabRepositories.queryOptions({ @@ -464,7 +466,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New }), enabled: Boolean(organizationId), }); - const { data: bitbucketStatusData } = useQuery({ + const { data: bitbucketStatusData, refetch: refetchBitbucketStatus } = useQuery({ ...trpc.organizations.bitbucket.getStatus.queryOptions({ organizationId: organizationId ?? '', }), @@ -703,59 +705,63 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New }) ); - const refreshBitbucketRepositories = useCallback(async () => { - if (!organizationId) return; + const refreshBitbucketRepositories = useCallback( + async (statusOverride?: typeof bitbucketStatusData) => { + if (!organizationId) return; - const bitbucketStatus = - bitbucketStatusData ?? - (await queryClient.fetchQuery( - trpc.organizations.bitbucket.getStatus.queryOptions({ + const bitbucketStatus = + statusOverride ?? + bitbucketStatusData ?? + (await queryClient.fetchQuery( + trpc.organizations.bitbucket.getStatus.queryOptions({ + organizationId, + }) + )); + + if (bitbucketStatus.integrationId && bitbucketStatus.canManage) { + const result = await refreshBitbucketRepositoriesFromProvider({ organizationId, - }) - )); + integrationId: bitbucketStatus.integrationId, + }); + if (result.status !== 'available') { + throw new Error(getBitbucketRepositoryRefreshFailureMessage(result.status)); + } + return; + } - if (bitbucketStatus.integrationId && bitbucketStatus.canManage) { - const result = await refreshBitbucketRepositoriesFromProvider({ - organizationId, - integrationId: bitbucketStatus.integrationId, + const result = await queryClient.fetchQuery({ + ...trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ + organizationId, + forceRefresh: true, + }), + staleTime: 0, + }); + if (shouldCacheBitbucketRepositoryRefreshResult(result.status)) { + queryClient.setQueryData( + trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryKey({ + organizationId, + forceRefresh: false, + }), + result + ); + } + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.bitbucket.getStatus.queryKey({ organizationId }), }); if (result.status !== 'available') { throw new Error(getBitbucketRepositoryRefreshFailureMessage(result.status)); } - return; - } - - const result = await queryClient.fetchQuery({ - ...trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }), - staleTime: 0, - }); - if (shouldCacheBitbucketRepositoryRefreshResult(result.status)) { - queryClient.setQueryData( - trpc.organizations.cloudAgentNext.listBitbucketRepositories.queryKey({ - organizationId, - forceRefresh: false, - }), - result - ); - } - void queryClient.invalidateQueries({ - queryKey: trpc.organizations.bitbucket.getStatus.queryKey({ organizationId }), - }); - if (result.status !== 'available') { - throw new Error(getBitbucketRepositoryRefreshFailureMessage(result.status)); - } - }, [ - bitbucketStatusData?.canManage, - bitbucketStatusData?.integrationId, - organizationId, - queryClient, - refreshBitbucketRepositoriesFromProvider, - trpc.organizations.bitbucket.getStatus, - trpc.organizations.cloudAgentNext.listBitbucketRepositories, - ]); + }, + [ + bitbucketStatusData?.canManage, + bitbucketStatusData?.integrationId, + organizationId, + queryClient, + refreshBitbucketRepositoriesFromProvider, + trpc.organizations.bitbucket.getStatus, + trpc.organizations.cloudAgentNext.listBitbucketRepositories, + ] + ); const shouldRefreshBitbucketRepositories = organizationId && shouldIncludeBitbucketRepositoryRefresh(bitbucketRepoData?.status); @@ -788,6 +794,51 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New isRefreshingBitbucketRepos || isRefetchingBitbucketRepos; + const [isCheckingConnection, setIsCheckingConnection] = useState(false); + const checkSourceControlConnection = useCallback(async () => { + setIsCheckingConnection(true); + + try { + const [githubResult, gitlabResult, bitbucketStatusResult] = await Promise.allSettled([ + refetchGitHubRepositories(), + refetchGitLabRepositories(), + organizationId ? refetchBitbucketStatus() : Promise.resolve(null), + ]); + const connectionAvailable = + (githubResult.status === 'fulfilled' && githubResult.value.data?.integrationInstalled) || + (gitlabResult.status === 'fulfilled' && gitlabResult.value.data?.integrationInstalled); + + if (connectionAvailable) return; + + if ( + bitbucketStatusResult.status === 'fulfilled' && + bitbucketStatusResult.value?.data?.status === 'connected' + ) { + await refreshBitbucketRepositories(bitbucketStatusResult.value.data); + return; + } + + const connectionError = [githubResult, gitlabResult, bitbucketStatusResult] + .map(result => (result.status === 'rejected' ? result.reason : result.value?.error)) + .find(Boolean); + if (connectionError) throw connectionError; + + toast.info('No repository provider connected yet'); + } catch (error) { + toast.error('Could not check source control connections', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCheckingConnection(false); + } + }, [ + organizationId, + refetchBitbucketStatus, + refetchGitHubRepositories, + refetchGitLabRepositories, + refreshBitbucketRepositories, + ]); + // --------------------------------------------------------------------------- // Integration missing check // --------------------------------------------------------------------------- @@ -1139,34 +1190,18 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New // Integration missing view // --------------------------------------------------------------------------- if (isIntegrationMissing) { - const integrationsPath = organizationId - ? `/organizations/${organizationId}/integrations` - : '/integrations'; - const integrationMessage = - githubRepoData?.errorMessage || - gitlabRepoData?.errorMessage || - 'Connect a source control integration to select a repository for Cloud Agent.'; - return ( -
+
new -
-
- -

Connect source control to start a session

-
-

{integrationMessage}

-
- - Open integrations - - router.refresh()}> - Refresh - -
+
+ void checkSourceControlConnection()} + />
); diff --git a/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.module.css b/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.module.css new file mode 100644 index 0000000000..67b25a7356 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.module.css @@ -0,0 +1,32 @@ +.shimmer { + color: var(--muted-foreground); + background-image: linear-gradient( + 100deg, + var(--foreground-subtle) 30%, + color-mix(in srgb, var(--foreground) 55%, var(--muted-foreground)) 50%, + var(--foreground-subtle) 70% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 3.2s linear infinite; +} + +@keyframes shimmer { + from { + background-position: 200% 50%; + } + + to { + background-position: -200% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .shimmer { + background-image: none; + -webkit-text-fill-color: currentColor; + animation: none; + } +} diff --git a/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.tsx b/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.tsx new file mode 100644 index 0000000000..0fe56cb8df --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/RepositoryProviderOnboardingPanel.tsx @@ -0,0 +1,124 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import styles from './RepositoryProviderOnboardingPanel.module.css'; + +const providerLogoPaths = { + github: + 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', + gitlab: + 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z', + bitbucket: + 'M.778 1.213a.768.768 0 0 0-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 0 0 .77-.646l3.27-20.03a.768.768 0 0 0-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z', +} as const; + +type ProviderLogoProps = { + provider: keyof typeof providerLogoPaths; + className?: string; +}; + +function ProviderLogo({ provider, className }: ProviderLogoProps) { + return ( + + ); +} + +type RepositoryProviderOnboardingPanelProps = { + organizationId?: string; + isCheckingConnection?: boolean; + onCheckConnection: () => void; +}; + +type RepositoryProvider = { + name: string; + href: string; + icon: ReactNode; +}; + +export function RepositoryProviderOnboardingPanel({ + organizationId, + isCheckingConnection = false, + onCheckConnection, +}: RepositoryProviderOnboardingPanelProps) { + const integrationBasePath = organizationId + ? `/organizations/${organizationId}/integrations` + : '/integrations'; + const providers: RepositoryProvider[] = [ + { + name: 'GitHub', + href: `${integrationBasePath}/github`, + icon: , + }, + { + name: 'GitLab', + href: `${integrationBasePath}/gitlab`, + icon: , + }, + ]; + + if (organizationId) { + providers.push({ + name: 'Bitbucket', + href: `${integrationBasePath}/bitbucket`, + icon: , + }); + } + + return ( +
+
+

+ Connect a repository provider +

+

+ Cloud Agent needs access to your code before it can start a session. +

+
+ +
+ {providers.map(provider => ( + +
+ {provider.icon} +
+ {provider.name} +
+ +

+ Already connected?{' '} + +

+
+ ); +}