diff --git a/app/commit.json b/app/commit.json
new file mode 100644
index 000000000..dcafaee1b
--- /dev/null
+++ b/app/commit.json
@@ -0,0 +1 @@
+{ "commit": "b07702d0bc09434df42448248354821658ad14e7" }
diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx
index 7c2a9da12..1534ad09d 100644
--- a/app/components/chat/BaseChat.tsx
+++ b/app/components/chat/BaseChat.tsx
@@ -545,7 +545,7 @@ export const BaseChat = React.forwardRef(
{!chatStarted && (
{ImportButtons(importChat)}
-
+ {importChat && }
)}
{!chatStarted &&
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx
index 4fe4c55e6..2313bf868 100644
--- a/app/components/chat/GitCloneButton.tsx
+++ b/app/components/chat/GitCloneButton.tsx
@@ -3,6 +3,8 @@ import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
+import { useState } from 'react';
+import { GitCloneModal } from '~/components/git/GitCloneModal';
const IGNORE_PATTERNS = [
'node_modules/**',
@@ -31,47 +33,43 @@ const IGNORE_PATTERNS = [
const ig = ignore().add(IGNORE_PATTERNS);
interface GitCloneButtonProps {
- className?: string;
importChat?: (description: string, messages: Message[]) => Promise;
}
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
- const onClick = async (_e: any) => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const handleClone = async (repoUrl: string) => {
if (!ready) {
return;
}
- const repoUrl = prompt('Enter the Git url');
-
- if (repoUrl) {
- const { workdir, data } = await gitClone(repoUrl);
-
- if (importChat) {
- const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
- console.log(filePaths);
+ const { workdir, data } = await gitClone(repoUrl);
- const textDecoder = new TextDecoder('utf-8');
+ if (importChat) {
+ const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
+ const textDecoder = new TextDecoder('utf-8');
- // Convert files to common format for command detection
- const fileContents = filePaths
- .map((filePath) => {
- const { data: content, encoding } = data[filePath];
- return {
- path: filePath,
- content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
- };
- })
- .filter((f) => f.content);
+ // Convert files to common format for command detection
+ const fileContents = filePaths
+ .map((filePath) => {
+ const { data: content, encoding } = data[filePath];
+ return {
+ path: filePath,
+ content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
+ };
+ })
+ .filter((f) => f.content);
- // Detect and create commands message
- const commands = await detectProjectCommands(fileContents);
- const commandsMessage = createCommandsMessage(commands);
+ // Detect and create commands message
+ const commands = await detectProjectCommands(fileContents);
+ const commandsMessage = createCommandsMessage(commands);
- // Create files message
- const filesMessage: Message = {
- role: 'assistant',
- content: `Cloning the repo ${repoUrl} into ${workdir}
+ // Create files message
+ const filesMessage: Message = {
+ role: 'assistant',
+ content: `Cloning the repo ${repoUrl} into ${workdir}
${fileContents
.map(
@@ -82,29 +80,32 @@ ${file.content}
)
.join('\n')}
`,
- id: generateId(),
- createdAt: new Date(),
- };
+ id: generateId(),
+ createdAt: new Date(),
+ };
- const messages = [filesMessage];
+ const messages = [filesMessage];
- if (commandsMessage) {
- messages.push(commandsMessage);
- }
-
- await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
+ if (commandsMessage) {
+ messages.push(commandsMessage);
}
+
+ await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
};
return (
-
+ <>
+
+
+ setIsModalOpen(false)} onClone={handleClone} />
+ >
);
}
diff --git a/app/components/git/GitCloneModal.tsx b/app/components/git/GitCloneModal.tsx
new file mode 100644
index 000000000..dbb3c9e7d
--- /dev/null
+++ b/app/components/git/GitCloneModal.tsx
@@ -0,0 +1,199 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Dialog, DialogRoot } from '~/components/ui/Dialog';
+import { GitHubAuth } from '~/components/github/GitHubAuth';
+import { getGitHubUser, getUserRepos } from '~/lib/github/github.client';
+import { toast } from 'react-toastify';
+import { GitCloneSpinner } from './GitCloneSpinner';
+
+interface GitCloneModalProps {
+ open: boolean;
+ onClose: () => void;
+ onClone: (url: string) => Promise;
+}
+
+export function GitCloneModal({ open, onClose, onClone }: GitCloneModalProps) {
+ const [publicUrl, setPublicUrl] = useState('');
+ const [userRepos, setUserRepos] = useState>([]);
+ const [selectedRepo, setSelectedRepo] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isCloning, setIsCloning] = useState(false);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [username, setUsername] = useState('');
+
+ const loadUserRepos = useCallback(async () => {
+ const token = localStorage.getItem('github_token');
+
+ if (!token) {
+ setIsAuthenticated(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ const user = await getGitHubUser(token);
+ setUsername(user.login);
+
+ const repos = await getUserRepos(token);
+ setUserRepos(
+ repos.map((repo) => ({
+ name: repo.full_name,
+ url: repo.clone_url,
+ })),
+ );
+ setIsAuthenticated(true);
+ } catch (error) {
+ console.error('Error loading repos:', error);
+ toast.error('Failed to load repositories');
+
+ if (error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403)) {
+ localStorage.removeItem('github_token');
+ setIsAuthenticated(false);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ loadUserRepos();
+ }
+ }, [open, loadUserRepos]);
+
+ const handleAuthComplete = useCallback(
+ async (token: string) => {
+ try {
+ const user = await getGitHubUser(token);
+ setUsername(user.login);
+ setIsAuthenticated(true);
+ loadUserRepos();
+ } catch (error) {
+ console.error('Auth error:', error);
+ toast.error('Authentication failed');
+ localStorage.removeItem('github_token');
+ setIsAuthenticated(false);
+ }
+ },
+ [loadUserRepos],
+ );
+
+ const handleClone = useCallback(async () => {
+ try {
+ const cloneUrl = selectedRepo || publicUrl;
+
+ if (cloneUrl) {
+ setIsCloning(true);
+ onClose(); // Close the modal immediately when starting clone
+ await onClone(cloneUrl);
+ setIsCloning(false);
+ }
+ } catch (error) {
+ console.error('Clone error:', error);
+ toast.error('Failed to clone repository');
+ setIsCloning(false);
+ }
+ }, [selectedRepo, publicUrl, onClone, onClose]);
+
+ return (
+ <>
+
+
+
+
+ {(!selectedRepo || !isAuthenticated) && (
+
+
Public Repository URL
+
{
+ setPublicUrl(e.target.value);
+
+ if (e.target.value && selectedRepo) {
+ setSelectedRepo('');
+ }
+ }}
+ className="w-full p-2 h-[32px] rounded bg-[#2D2D2D] border border-[#383838] text-white placeholder-[#8B8B8B] focus:outline-none focus:border-[#525252]"
+ />
+
+ )}
+
+
+
+ {isAuthenticated ? `${username}'s GitHub Repositories` : 'Your GitHub Repositories'}
+
+ {isAuthenticated ? (
+
+ ) : (
+
toast.error(error.message)}>
+
+
+ )}
+ {isLoading && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/components/git/GitCloneSpinner.tsx b/app/components/git/GitCloneSpinner.tsx
new file mode 100644
index 000000000..8b0ca60b4
--- /dev/null
+++ b/app/components/git/GitCloneSpinner.tsx
@@ -0,0 +1,25 @@
+interface GitCloneSpinnerProps {
+ isOpen: boolean;
+}
+
+export function GitCloneSpinner({ isOpen }: GitCloneSpinnerProps) {
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <>
+ {/* Full screen blocker that prevents all interactions */}
+
+
+ {/* Spinner overlay */}
+
+
+
+
Cloning Repository...
+
This may take a few moments
+
+
+ >
+ );
+}
diff --git a/app/components/github/GitHubAuth.tsx b/app/components/github/GitHubAuth.tsx
new file mode 100644
index 000000000..c77db2c94
--- /dev/null
+++ b/app/components/github/GitHubAuth.tsx
@@ -0,0 +1,215 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { GITHUB_CONFIG } from '~/lib/github/config';
+
+interface GitHubAuthProps {
+ onAuthComplete?: (token: string) => void;
+ onError?: (error: Error) => void;
+ onAuthStart?: () => void;
+ children?: React.ReactNode;
+}
+
+interface GitHubErrorResponse {
+ error?: string;
+ error_description?: string;
+}
+
+interface DeviceCodeResponse extends GitHubErrorResponse {
+ device_code: string;
+ user_code: string;
+ verification_uri: string;
+ expires_in: number;
+ interval: number;
+}
+
+interface AccessTokenResponse extends GitHubErrorResponse {
+ access_token?: string;
+}
+
+export function GitHubAuth({ onAuthComplete, onError, onAuthStart, children }: GitHubAuthProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [userCode, setUserCode] = useState(null);
+ const [verificationUrl, setVerificationUrl] = useState(null);
+ const [isPolling, setIsPolling] = useState(false);
+ const [showCopied, setShowCopied] = useState(false);
+
+ // Reset states when auth completes
+ const handleAuthSuccess = useCallback(
+ (token: string) => {
+ setUserCode(null);
+ setVerificationUrl(null);
+ setIsPolling(false);
+ setIsLoading(false);
+ onAuthComplete?.(token);
+ },
+ [onAuthComplete],
+ );
+
+ const pollForToken = useCallback(
+ async (code: string, interval: number, attempts = 0) => {
+ if (attempts >= GITHUB_CONFIG.maxPollAttempts) {
+ setIsPolling(false);
+ onError?.(new Error('Authentication timed out. Please try again.'));
+
+ return;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ endpoint: GITHUB_CONFIG.accessTokenEndpoint,
+ client_id: GITHUB_CONFIG.clientId,
+ device_code: code,
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
+ });
+
+ const response = await fetch(`/api/github/proxy?${params}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const data = (await response.json()) as AccessTokenResponse;
+
+ if (data.error === 'authorization_pending') {
+ // Authorization is pending, continue polling
+ setTimeout(() => pollForToken(code, interval, attempts + 1), interval * 1000);
+ return;
+ }
+
+ if (data.error || data.error_description) {
+ throw new Error(data.error_description || data.error || 'Authentication failed');
+ }
+
+ if (!data.access_token) {
+ throw new Error('Invalid response from GitHub');
+ }
+
+ localStorage.setItem('github_token', data.access_token);
+ handleAuthSuccess(data.access_token);
+ } catch (error: any) {
+ setIsPolling(false);
+ onError?.(error);
+ }
+ },
+ [onAuthComplete, onError],
+ );
+
+ const handleStartAuth = useCallback(async () => {
+ try {
+ setIsLoading(true);
+
+ const params = new URLSearchParams({
+ endpoint: GITHUB_CONFIG.deviceCodeEndpoint,
+ client_id: GITHUB_CONFIG.clientId,
+ scope: GITHUB_CONFIG.scope,
+ });
+
+ const response = await fetch(`/api/github/proxy?${params}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const data = (await response.json()) as DeviceCodeResponse;
+
+ if (data.error || data.error_description) {
+ throw new Error(data.error_description || data.error || 'Failed to start authentication process');
+ }
+
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
+ throw new Error('Invalid response from GitHub');
+ }
+
+ setUserCode(data.user_code);
+ setVerificationUrl(data.verification_uri);
+ setIsPolling(true);
+
+ pollForToken(data.device_code, data.interval || 5);
+ } catch (error: any) {
+ setIsLoading(false);
+ onError?.(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [pollForToken, onError]);
+
+ useEffect(() => {
+ if (!children) {
+ handleStartAuth();
+ }
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ setIsPolling(false);
+ setIsLoading(false);
+ };
+ }, []);
+
+ const handleCopyCode = useCallback(() => {
+ if (userCode) {
+ navigator.clipboard.writeText(userCode);
+ setShowCopied(true);
+ setTimeout(() => setShowCopied(false), 2000);
+ }
+ }, [userCode]);
+
+ if (isLoading) {
+ return (
+
+
+
Initializing GitHub authentication...
+
+ );
+ }
+
+ if (userCode && verificationUrl) {
+ return (
+
+
+ Enter this code at{' '}
+
+ {verificationUrl}
+
+
+
+
{userCode}
+
+
+ {isPolling && (
+
+ Waiting for authorization... You can close the GitHub window once authorized.
+
+ )}
+
+ );
+ }
+
+ if (!children) {
+ return null;
+ }
+
+ return React.cloneElement(children as React.ReactElement, {
+ onClick: (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ if (onAuthStart) {
+ onAuthStart();
+ }
+
+ handleStartAuth();
+ (children as React.ReactElement).props.onClick?.(e);
+ },
+ });
+}
diff --git a/app/components/github/GitHubAuthModal.tsx b/app/components/github/GitHubAuthModal.tsx
new file mode 100644
index 000000000..6e1ebcac4
--- /dev/null
+++ b/app/components/github/GitHubAuthModal.tsx
@@ -0,0 +1,325 @@
+import { Dialog, DialogRoot } from '~/components/ui/Dialog';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { workbenchStore } from '~/lib/stores/workbench';
+import { Octokit } from '@octokit/rest';
+import { toast } from 'react-toastify';
+import { GitHubAuth } from '~/components/github/GitHubAuth';
+import { getGitHubUser } from '~/lib/github/github.client';
+
+interface GitHubAuthModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onPushComplete?: (success: boolean, repoUrl?: string) => void;
+ onAuthComplete?: (token: string) => void;
+ initialToken?: string | null;
+}
+
+export function GitHubAuthModal({
+ isOpen,
+ onClose,
+ onPushComplete,
+ onAuthComplete,
+ initialToken,
+}: GitHubAuthModalProps) {
+ const [error, setError] = useState(null);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [repoName, setRepoName] = useState('bolt-generated-project');
+ const [repoVisibility, setRepoVisibility] = useState(false);
+ const [user, setUser] = useState<{ login: string } | null>(null);
+ const [token, setToken] = useState(null);
+ const [isAuthenticating, setIsAuthenticating] = useState(false);
+ const [isUpdatingVisibility, setIsUpdatingVisibility] = useState(false);
+ const hasShownToast = useRef(false);
+ const checkTimeoutRef = useRef();
+
+ // If we have an initial token, validate and use it
+ useEffect(() => {
+ if (initialToken && !isAuthenticated) {
+ getGitHubUser(initialToken)
+ .then((githubUser) => {
+ setUser(githubUser);
+ setToken(initialToken);
+ setIsAuthenticated(true);
+ })
+ .catch((error) => {
+ console.error('Failed to validate token:', error);
+ setError('Failed to validate GitHub token. Please authenticate again.');
+ });
+ }
+ }, [initialToken, isAuthenticated]);
+
+ const checkRepoVisibility = useCallback(
+ async (name: string) => {
+ if (!isAuthenticated || !user || !token) {
+ return;
+ }
+
+ try {
+ const octokit = new Octokit({ auth: token });
+
+ try {
+ const { data: repo } = await octokit.repos.get({
+ owner: user.login,
+ repo: name,
+ });
+ setRepoVisibility(repo.private);
+ } catch (error) {
+ if (error instanceof Error && 'status' in error && error.status === 404) {
+ // Repository doesn't exist yet, set to public
+ setRepoVisibility(false);
+ } else {
+ console.error('Error checking repo visibility:', error);
+ }
+ }
+ } catch (error) {
+ console.error('Error initializing Octokit:', error);
+ }
+ },
+ [isAuthenticated, user, token],
+ );
+
+ const handleRepoNameChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newName = e.target.value;
+ setRepoName(newName);
+
+ // Clear any existing timeout
+ if (checkTimeoutRef.current) {
+ clearTimeout(checkTimeoutRef.current);
+ }
+
+ // Set new timeout to check repo after 1000ms of no typing
+ checkTimeoutRef.current = setTimeout(() => {
+ checkRepoVisibility(newName);
+ }, 1000);
+ },
+ [checkRepoVisibility],
+ );
+
+ // Clean up timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (checkTimeoutRef.current) {
+ clearTimeout(checkTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleAuthComplete = useCallback(async (authToken: string) => {
+ setIsAuthenticating(true);
+
+ try {
+ const githubUser = await getGitHubUser(authToken);
+ setUser(githubUser);
+ setToken(authToken);
+ setIsAuthenticated(true);
+
+ if (!hasShownToast.current) {
+ toast.success('Successfully authenticated with GitHub!');
+ hasShownToast.current = true;
+ }
+ } catch (error: any) {
+ console.error('Failed to get GitHub user:', error);
+ setError('Failed to get GitHub user info. Please try again.');
+ toast.error('Failed to authenticate with GitHub: ' + (error.message || 'Unknown error'));
+ } finally {
+ setIsAuthenticating(false);
+ }
+ }, []);
+
+ const handleCreateRepo = useCallback(async () => {
+ if (!repoName.trim()) {
+ setError('Repository name is required');
+ return;
+ }
+
+ if (!token || !user) {
+ setError('Not authenticated with GitHub');
+ return;
+ }
+
+ onAuthComplete?.(token);
+
+ try {
+ // Always use force push
+ const result = await workbenchStore.pushToGitHub(repoName, user.login, token, true, repoVisibility);
+ onPushComplete?.(true, result.html_url);
+ } catch (error) {
+ console.error('Failed to push to GitHub:', error);
+ setError(error instanceof Error ? error.message : 'Failed to push to GitHub');
+ onPushComplete?.(false);
+ }
+ }, [repoName, user, token, onAuthComplete, onPushComplete, repoVisibility]);
+
+ // Monitor localStorage for GitHub token
+ useEffect(() => {
+ if (isAuthenticating) {
+ const checkToken = () => {
+ const token = localStorage.getItem('github_token');
+
+ if (token) {
+ setIsAuthenticating(false);
+ handleAuthComplete(token);
+ }
+
+ return undefined;
+ };
+
+ // Check immediately and then set up interval
+ checkToken();
+
+ const interval = setInterval(checkToken, 500);
+
+ // Cleanup interval
+ return () => clearInterval(interval);
+ }
+
+ return undefined;
+ }, [isAuthenticating, handleAuthComplete]);
+
+ // Clear state when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setError(null);
+ setIsAuthenticated(false);
+ setRepoName('bolt-generated-project');
+ setRepoVisibility(false);
+ setUser(null);
+ setToken(null);
+ hasShownToast.current = false;
+ }
+ }, [isOpen]);
+
+ return (
+
+ {isAuthenticating ? (
+
+
+
+
Authenticating with GitHub...
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/components/github/GitHubPushOverlay.tsx b/app/components/github/GitHubPushOverlay.tsx
new file mode 100644
index 000000000..f65ecb935
--- /dev/null
+++ b/app/components/github/GitHubPushOverlay.tsx
@@ -0,0 +1,10 @@
+export function GitHubPushOverlay() {
+ return (
+
+
+
+
Pushing your project to GitHub...
+
+
+ );
+}
diff --git a/app/components/github/useGitHubPush.tsx b/app/components/github/useGitHubPush.tsx
new file mode 100644
index 000000000..c8d203ed7
--- /dev/null
+++ b/app/components/github/useGitHubPush.tsx
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import { getGitHubUser } from '~/lib/github/github.client';
+
+export function useGitHubPush() {
+ const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
+ const [isPushingToGitHub, setIsPushingToGitHub] = useState(false);
+
+ const handlePushToGitHub = async () => {
+ try {
+ // Check for existing GitHub token
+ const existingToken = localStorage.getItem('github_token');
+
+ if (existingToken) {
+ // Get the GitHub user info directly to validate token
+ await getGitHubUser(existingToken);
+ }
+
+ // Show auth modal, passing the existing token if we have one
+ setIsAuthModalOpen(true);
+ } catch (error) {
+ console.error('Failed to use existing GitHub token:', error);
+
+ // If token is invalid, remove it
+ localStorage.removeItem('github_token');
+ setIsAuthModalOpen(true);
+ }
+ };
+
+ const handleAuthComplete = async () => {
+ setIsAuthModalOpen(false);
+ setIsPushingToGitHub(true);
+ };
+
+ const handlePushComplete = (success: boolean, repoUrl?: string) => {
+ setIsPushingToGitHub(false);
+
+ if (success) {
+ toast.success(
+ ,
+ { autoClose: 5000 },
+ );
+ } else {
+ toast.error('Failed to push to GitHub. Please try again.');
+ }
+ };
+
+ return {
+ isAuthModalOpen,
+ isPushingToGitHub,
+ setIsAuthModalOpen,
+ handlePushToGitHub,
+ handleAuthComplete,
+ handlePushComplete,
+ };
+}
diff --git a/app/components/settings/features/FeaturesTab.tsx b/app/components/settings/features/FeaturesTab.tsx
index 0af3d401c..7fb4afaea 100644
--- a/app/components/settings/features/FeaturesTab.tsx
+++ b/app/components/settings/features/FeaturesTab.tsx
@@ -60,6 +60,7 @@ export default function FeaturesTab() {