diff --git a/server/index.js b/server/index.js
index 4562f80..28ba951 100755
--- a/server/index.js
+++ b/server/index.js
@@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
+import projectsRoutes from './routes/projects.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -201,6 +202,9 @@ app.use('/api', validateApiKey);
// Authentication routes (public)
app.use('/api/auth', authRoutes);
+// Projects API Routes (protected)
+app.use('/api/projects', authenticateToken, projectsRoutes);
+
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
diff --git a/server/routes/projects.js b/server/routes/projects.js
new file mode 100644
index 0000000..042de51
--- /dev/null
+++ b/server/routes/projects.js
@@ -0,0 +1,378 @@
+import express from 'express';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { spawn } from 'child_process';
+import os from 'os';
+import { addProjectManually } from '../projects.js';
+
+const router = express.Router();
+
+// Configure allowed workspace root (defaults to user's home directory)
+const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
+
+// System-critical paths that should never be used as workspace directories
+const FORBIDDEN_PATHS = [
+ '/',
+ '/etc',
+ '/bin',
+ '/sbin',
+ '/usr',
+ '/dev',
+ '/proc',
+ '/sys',
+ '/var',
+ '/boot',
+ '/root',
+ '/lib',
+ '/lib64',
+ '/opt',
+ '/tmp',
+ '/run'
+];
+
+/**
+ * Validates that a path is safe for workspace operations
+ * @param {string} requestedPath - The path to validate
+ * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
+ */
+async function validateWorkspacePath(requestedPath) {
+ try {
+ // Resolve to absolute path
+ let absolutePath = path.resolve(requestedPath);
+
+ // Check if path is a forbidden system directory
+ const normalizedPath = path.normalize(absolutePath);
+ if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
+ return {
+ valid: false,
+ error: 'Cannot use system-critical directories as workspace locations'
+ };
+ }
+
+ // Additional check for paths starting with forbidden directories
+ for (const forbidden of FORBIDDEN_PATHS) {
+ if (normalizedPath === forbidden ||
+ normalizedPath.startsWith(forbidden + path.sep)) {
+ // Exception: /var/tmp and similar user-accessible paths might be allowed
+ // but /var itself and most /var subdirectories should be blocked
+ if (forbidden === '/var' &&
+ (normalizedPath.startsWith('/var/tmp') ||
+ normalizedPath.startsWith('/var/folders'))) {
+ continue; // Allow these specific cases
+ }
+
+ return {
+ valid: false,
+ error: `Cannot create workspace in system directory: ${forbidden}`
+ };
+ }
+ }
+
+ // Try to resolve the real path (following symlinks)
+ let realPath;
+ try {
+ // Check if path exists to resolve real path
+ await fs.access(absolutePath);
+ realPath = await fs.realpath(absolutePath);
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ // Path doesn't exist yet - check parent directory
+ let parentPath = path.dirname(absolutePath);
+ try {
+ const parentRealPath = await fs.realpath(parentPath);
+
+ // Reconstruct the full path with real parent
+ realPath = path.join(parentRealPath, path.basename(absolutePath));
+ } catch (parentError) {
+ if (parentError.code === 'ENOENT') {
+ // Parent doesn't exist either - use the absolute path as-is
+ // We'll validate it's within allowed root
+ realPath = absolutePath;
+ } else {
+ throw parentError;
+ }
+ }
+ } else {
+ throw error;
+ }
+ }
+
+ // Resolve the workspace root to its real path
+ const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
+
+ // Ensure the resolved path is contained within the allowed workspace root
+ if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
+ realPath !== resolvedWorkspaceRoot) {
+ return {
+ valid: false,
+ error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
+ };
+ }
+
+ // Additional symlink check for existing paths
+ try {
+ await fs.access(absolutePath);
+ const stats = await fs.lstat(absolutePath);
+
+ if (stats.isSymbolicLink()) {
+ // Verify symlink target is also within allowed root
+ const linkTarget = await fs.readlink(absolutePath);
+ const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
+ const realTarget = await fs.realpath(resolvedTarget);
+
+ if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
+ realTarget !== resolvedWorkspaceRoot) {
+ return {
+ valid: false,
+ error: 'Symlink target is outside the allowed workspace root'
+ };
+ }
+ }
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ throw error;
+ }
+ // Path doesn't exist - that's fine for new workspace creation
+ }
+
+ return {
+ valid: true,
+ resolvedPath: realPath
+ };
+
+ } catch (error) {
+ return {
+ valid: false,
+ error: `Path validation failed: ${error.message}`
+ };
+ }
+}
+
+/**
+ * Create a new workspace
+ * POST /api/projects/create-workspace
+ *
+ * Body:
+ * - workspaceType: 'existing' | 'new'
+ * - path: string (workspace path)
+ * - githubUrl?: string (optional, for new workspaces)
+ * - githubTokenId?: number (optional, ID of stored token)
+ * - newGithubToken?: string (optional, one-time token)
+ */
+router.post('/create-workspace', async (req, res) => {
+ try {
+ const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
+
+ // Validate required fields
+ if (!workspaceType || !workspacePath) {
+ return res.status(400).json({ error: 'workspaceType and path are required' });
+ }
+
+ if (!['existing', 'new'].includes(workspaceType)) {
+ return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
+ }
+
+ // Validate path safety before any operations
+ const validation = await validateWorkspacePath(workspacePath);
+ if (!validation.valid) {
+ return res.status(400).json({
+ error: 'Invalid workspace path',
+ details: validation.error
+ });
+ }
+
+ const absolutePath = validation.resolvedPath;
+
+ // Handle existing workspace
+ if (workspaceType === 'existing') {
+ // Check if the path exists
+ try {
+ await fs.access(absolutePath);
+ const stats = await fs.stat(absolutePath);
+
+ if (!stats.isDirectory()) {
+ return res.status(400).json({ error: 'Path exists but is not a directory' });
+ }
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return res.status(404).json({ error: 'Workspace path does not exist' });
+ }
+ throw error;
+ }
+
+ // Add the existing workspace to the project list
+ const project = await addProjectManually(absolutePath);
+
+ return res.json({
+ success: true,
+ project,
+ message: 'Existing workspace added successfully'
+ });
+ }
+
+ // Handle new workspace creation
+ if (workspaceType === 'new') {
+ // Check if path already exists
+ try {
+ await fs.access(absolutePath);
+ return res.status(400).json({
+ error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
+ });
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ throw error;
+ }
+ // Path doesn't exist - good, we can create it
+ }
+
+ // Create the directory
+ await fs.mkdir(absolutePath, { recursive: true });
+
+ // If GitHub URL is provided, clone the repository
+ if (githubUrl) {
+ let githubToken = null;
+
+ // Get GitHub token if needed
+ if (githubTokenId) {
+ // Fetch token from database
+ const token = await getGithubTokenById(githubTokenId, req.user.id);
+ if (!token) {
+ // Clean up created directory
+ await fs.rm(absolutePath, { recursive: true, force: true });
+ return res.status(404).json({ error: 'GitHub token not found' });
+ }
+ githubToken = token.github_token;
+ } else if (newGithubToken) {
+ githubToken = newGithubToken;
+ }
+
+ // Clone the repository
+ try {
+ await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
+ } catch (error) {
+ // Clean up created directory on failure
+ try {
+ await fs.rm(absolutePath, { recursive: true, force: true });
+ } catch (cleanupError) {
+ console.error('Failed to clean up directory after clone failure:', cleanupError);
+ // Continue to throw original error
+ }
+ throw new Error(`Failed to clone repository: ${error.message}`);
+ }
+ }
+
+ // Add the new workspace to the project list
+ const project = await addProjectManually(absolutePath);
+
+ return res.json({
+ success: true,
+ project,
+ message: githubUrl
+ ? 'New workspace created and repository cloned successfully'
+ : 'New workspace created successfully'
+ });
+ }
+
+ } catch (error) {
+ console.error('Error creating workspace:', error);
+ res.status(500).json({
+ error: error.message || 'Failed to create workspace',
+ details: process.env.NODE_ENV === 'development' ? error.stack : undefined
+ });
+ }
+});
+
+/**
+ * Helper function to get GitHub token from database
+ */
+async function getGithubTokenById(tokenId, userId) {
+ const { getDatabase } = await import('../database/db.js');
+ const db = await getDatabase();
+
+ const credential = await db.get(
+ 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
+ [tokenId, userId, 'github_token']
+ );
+
+ // Return in the expected format (github_token field for compatibility)
+ if (credential) {
+ return {
+ ...credential,
+ github_token: credential.credential_value
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Helper function to clone a GitHub repository
+ */
+function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
+ return new Promise((resolve, reject) => {
+ // Parse GitHub URL and inject token if provided
+ let cloneUrl = githubUrl;
+
+ if (githubToken) {
+ try {
+ const url = new URL(githubUrl);
+ // Format: https://TOKEN@github.com/user/repo.git
+ url.username = githubToken;
+ url.password = '';
+ cloneUrl = url.toString();
+ } catch (error) {
+ return reject(new Error('Invalid GitHub URL format'));
+ }
+ }
+
+ const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
+ }
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ gitProcess.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ gitProcess.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ gitProcess.on('close', (code) => {
+ if (code === 0) {
+ resolve({ stdout, stderr });
+ } else {
+ // Parse git error messages to provide helpful feedback
+ let errorMessage = 'Git clone failed';
+
+ if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
+ errorMessage = 'Authentication failed. Please check your GitHub token.';
+ } else if (stderr.includes('Repository not found')) {
+ errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
+ } else if (stderr.includes('already exists')) {
+ errorMessage = 'Directory already exists';
+ } else if (stderr) {
+ errorMessage = stderr;
+ }
+
+ reject(new Error(errorMessage));
+ }
+ });
+
+ gitProcess.on('error', (error) => {
+ if (error.code === 'ENOENT') {
+ reject(new Error('Git is not installed or not in PATH'));
+ } else {
+ reject(error);
+ }
+ });
+ });
+}
+
+export default router;
diff --git a/src/components/ApiKeysSettings.jsx b/src/components/ApiKeysSettings.jsx
index 8a8c25b..c2d427c 100644
--- a/src/components/ApiKeysSettings.jsx
+++ b/src/components/ApiKeysSettings.jsx
@@ -33,11 +33,11 @@ function ApiKeysSettings() {
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
- const githubRes = await fetch('/api/settings/github-tokens', {
+ const githubRes = await fetch('/api/settings/credentials?type=github_token', {
headers: { 'Authorization': `Bearer ${token}` }
});
const githubData = await githubRes.json();
- setGithubTokens(githubData.tokens || []);
+ setGithubTokens(githubData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
@@ -108,15 +108,16 @@ function ApiKeysSettings() {
try {
const token = localStorage.getItem('auth-token');
- const res = await fetch('/api/settings/github-tokens', {
+ const res = await fetch('/api/settings/credentials', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
- tokenName: newTokenName,
- githubToken: newGithubToken
+ credentialName: newTokenName,
+ credentialType: 'github_token',
+ credentialValue: newGithubToken
})
});
@@ -137,7 +138,7 @@ function ApiKeysSettings() {
try {
const token = localStorage.getItem('auth-token');
- await fetch(`/api/settings/github-tokens/${tokenId}`, {
+ await fetch(`/api/settings/credentials/${tokenId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
@@ -150,7 +151,7 @@ function ApiKeysSettings() {
const toggleGithubToken = async (tokenId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
- await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
+ await fetch(`/api/settings/credentials/${tokenId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
@@ -349,7 +350,7 @@ function ApiKeysSettings() {
className="flex items-center justify-between p-3 border rounded-lg"
>
-
{token.token_name}
+
{token.credential_name}
Added: {new Date(token.created_at).toLocaleDateString()}
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx
index 8d452d3..e8b1302 100644
--- a/src/components/ChatInterface.jsx
+++ b/src/components/ChatInterface.jsx
@@ -4744,13 +4744,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Hint text inside input box at bottom */}
-
+
{sendByCtrlEnter
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
{sendByCtrlEnter
? "Ctrl+Enter to send • Tab for modes • / for commands"
diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx
new file mode 100644
index 0000000..38564dc
--- /dev/null
+++ b/src/components/ProjectCreationWizard.jsx
@@ -0,0 +1,570 @@
+import React, { useState, useEffect } from 'react';
+import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+import { api } from '../utils/api';
+
+const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
+ // Wizard state
+ const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
+ const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
+
+ // Form state
+ const [workspacePath, setWorkspacePath] = useState('');
+ const [githubUrl, setGithubUrl] = useState('');
+ const [selectedGithubToken, setSelectedGithubToken] = useState('');
+ const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
+ const [newGithubToken, setNewGithubToken] = useState('');
+
+ // UI state
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState(null);
+ const [availableTokens, setAvailableTokens] = useState([]);
+ const [loadingTokens, setLoadingTokens] = useState(false);
+ const [pathSuggestions, setPathSuggestions] = useState([]);
+ const [showPathDropdown, setShowPathDropdown] = useState(false);
+
+ // Load available GitHub tokens when needed
+ useEffect(() => {
+ if (step === 2 && workspaceType === 'new' && githubUrl) {
+ loadGithubTokens();
+ }
+ }, [step, workspaceType, githubUrl]);
+
+ // Load path suggestions
+ useEffect(() => {
+ if (workspacePath.length > 2) {
+ loadPathSuggestions(workspacePath);
+ } else {
+ setPathSuggestions([]);
+ setShowPathDropdown(false);
+ }
+ }, [workspacePath]);
+
+ const loadGithubTokens = async () => {
+ try {
+ setLoadingTokens(true);
+ const response = await api.get('/settings/credentials?type=github_token');
+ const data = await response.json();
+
+ const activeTokens = (data.credentials || []).filter(t => t.is_active);
+ setAvailableTokens(activeTokens);
+
+ // Auto-select first token if available
+ if (activeTokens.length > 0 && !selectedGithubToken) {
+ setSelectedGithubToken(activeTokens[0].id.toString());
+ }
+ } catch (error) {
+ console.error('Error loading GitHub tokens:', error);
+ } finally {
+ setLoadingTokens(false);
+ }
+ };
+
+ const loadPathSuggestions = async (inputPath) => {
+ try {
+ // Extract the directory to browse (parent of input)
+ const lastSlash = inputPath.lastIndexOf('/');
+ const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
+
+ const response = await api.browseFilesystem(dirPath);
+ const data = await response.json();
+
+ if (data.suggestions) {
+ // Filter suggestions based on the input
+ const filtered = data.suggestions.filter(s =>
+ s.path.toLowerCase().startsWith(inputPath.toLowerCase())
+ );
+ setPathSuggestions(filtered.slice(0, 5));
+ setShowPathDropdown(filtered.length > 0);
+ }
+ } catch (error) {
+ console.error('Error loading path suggestions:', error);
+ }
+ };
+
+ const handleNext = () => {
+ setError(null);
+
+ if (step === 1) {
+ if (!workspaceType) {
+ setError('Please select whether you have an existing workspace or want to create a new one');
+ return;
+ }
+ setStep(2);
+ } else if (step === 2) {
+ if (!workspacePath.trim()) {
+ setError('Please provide a workspace path');
+ return;
+ }
+
+ // No validation for GitHub token - it's optional (only needed for private repos)
+ setStep(3);
+ }
+ };
+
+ const handleBack = () => {
+ setError(null);
+ setStep(step - 1);
+ };
+
+ const handleCreate = async () => {
+ setIsCreating(true);
+ setError(null);
+
+ try {
+ const payload = {
+ workspaceType,
+ path: workspacePath.trim(),
+ };
+
+ // Add GitHub info if creating new workspace with GitHub URL
+ if (workspaceType === 'new' && githubUrl) {
+ payload.githubUrl = githubUrl.trim();
+
+ if (tokenMode === 'stored' && selectedGithubToken) {
+ payload.githubTokenId = parseInt(selectedGithubToken);
+ } else if (tokenMode === 'new' && newGithubToken) {
+ payload.newGithubToken = newGithubToken.trim();
+ }
+ }
+
+ const response = await api.createWorkspace(payload);
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Failed to create workspace');
+ }
+
+ // Success!
+ if (onProjectCreated) {
+ onProjectCreated(data.project);
+ }
+
+ onClose();
+ } catch (error) {
+ console.error('Error creating workspace:', error);
+ setError(error.message || 'Failed to create workspace');
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const selectPathSuggestion = (suggestion) => {
+ setWorkspacePath(suggestion.path);
+ setShowPathDropdown(false);
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ Create New Project
+
+
+
+
+
+
+
+ {/* Progress Indicator */}
+
+
+ {[1, 2, 3].map((s) => (
+
+
+
+ {s < step ? : s}
+
+
+ {s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
+
+
+ {s < 3 && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Content */}
+
+ {/* Error Display */}
+ {error && (
+
+ )}
+
+ {/* Step 1: Choose workspace type */}
+ {step === 1 && (
+
+
+
+ Do you already have a workspace, or would you like to create a new one?
+
+
+ {/* Existing Workspace */}
+
setWorkspaceType('existing')}
+ className={`p-4 border-2 rounded-lg text-left transition-all ${
+ workspaceType === 'existing'
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
+ }`}
+ >
+
+
+
+
+
+
+ Existing Workspace
+
+
+ I already have a workspace on my server and just need to add it to the project list
+
+
+
+
+
+ {/* New Workspace */}
+
setWorkspaceType('new')}
+ className={`p-4 border-2 rounded-lg text-left transition-all ${
+ workspaceType === 'new'
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
+ }`}
+ >
+
+
+
+
+
+
+ New Workspace
+
+
+ Create a new workspace, optionally clone from a GitHub repository
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 2: Configure workspace */}
+ {step === 2 && (
+
+ {/* Workspace Path */}
+
+
+ {workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
+
+
+
setWorkspacePath(e.target.value)}
+ placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
+ className="w-full"
+ />
+ {showPathDropdown && pathSuggestions.length > 0 && (
+
+ {pathSuggestions.map((suggestion, index) => (
+
selectPathSuggestion(suggestion)}
+ className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
+ >
+ {suggestion.name}
+ {suggestion.path}
+
+ ))}
+
+ )}
+
+
+ {workspaceType === 'existing'
+ ? 'Full path to your existing workspace directory'
+ : 'Full path where the new workspace will be created'}
+
+
+
+ {/* GitHub URL (only for new workspace) */}
+ {workspaceType === 'new' && (
+ <>
+
+
+ GitHub URL (Optional)
+
+
setGithubUrl(e.target.value)}
+ placeholder="https://github.com/username/repository"
+ className="w-full"
+ />
+
+ Leave empty to create an empty workspace, or provide a GitHub URL to clone
+
+
+
+ {/* GitHub Token (only if GitHub URL is provided) */}
+ {githubUrl && (
+
+
+
+
+
+ GitHub Authentication (Optional)
+
+
+ Only required for private repositories. Public repos can be cloned without authentication.
+
+
+
+
+ {loadingTokens ? (
+
+
+ Loading stored tokens...
+
+ ) : availableTokens.length > 0 ? (
+ <>
+ {/* Token Selection Tabs */}
+
+ setTokenMode('stored')}
+ className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
+ tokenMode === 'stored'
+ ? 'bg-blue-500 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
+ }`}
+ >
+ Stored Token
+
+ setTokenMode('new')}
+ className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
+ tokenMode === 'new'
+ ? 'bg-blue-500 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
+ }`}
+ >
+ New Token
+
+ {
+ setTokenMode('none');
+ setSelectedGithubToken('');
+ setNewGithubToken('');
+ }}
+ className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
+ tokenMode === 'none'
+ ? 'bg-green-500 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
+ }`}
+ >
+ None (Public)
+
+
+
+ {tokenMode === 'stored' ? (
+
+
+ Select Token
+
+ setSelectedGithubToken(e.target.value)}
+ className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
+ >
+ -- Select a token --
+ {availableTokens.map((token) => (
+
+ {token.credential_name}
+
+ ))}
+
+
+ ) : tokenMode === 'new' ? (
+
+
+ GitHub Token
+
+
setNewGithubToken(e.target.value)}
+ placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ className="w-full"
+ />
+
+ This token will be used only for this operation
+
+
+ ) : null}
+ >
+ ) : (
+
+
+
+ 💡 Public repositories don't require authentication. You can skip providing a token if cloning a public repo.
+
+
+
+
+
+ GitHub Token (Optional for Public Repos)
+
+
setNewGithubToken(e.target.value)}
+ placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
+ className="w-full"
+ />
+
+ No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Step 3: Confirm */}
+ {step === 3 && (
+
+
+
+ Review Your Configuration
+
+
+
+ Workspace Type:
+
+ {workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
+
+
+
+ Path:
+
+ {workspacePath}
+
+
+ {workspaceType === 'new' && githubUrl && (
+ <>
+
+ Clone From:
+
+ {githubUrl}
+
+
+
+ Authentication:
+
+ {tokenMode === 'stored' && selectedGithubToken
+ ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
+ : tokenMode === 'new' && newGithubToken
+ ? 'Using provided token'
+ : 'No authentication'}
+
+
+ >
+ )}
+
+
+
+
+
+ {workspaceType === 'existing'
+ ? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.'
+ : githubUrl
+ ? 'A new workspace will be created and the repository will be cloned from GitHub.'
+ : 'An empty workspace directory will be created at the specified path.'}
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {step === 1 ? (
+ 'Cancel'
+ ) : (
+ <>
+
+ Back
+ >
+ )}
+
+
+
+ {isCreating ? (
+ <>
+
+ Creating...
+ >
+ ) : step === 3 ? (
+ <>
+
+ Create Project
+ >
+ ) : (
+ <>
+ Next
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default ProjectCreationWizard;
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index f5c3e06..f619e7f 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
+import ReactDOM from 'react-dom';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -9,6 +10,7 @@ import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
import TaskIndicator from './TaskIndicator';
+import ProjectCreationWizard from './ProjectCreationWizard';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
@@ -64,8 +66,6 @@ function Sidebar({
const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
- const [newProjectPath, setNewProjectPath] = useState('');
- const [creatingProject, setCreatingProject] = useState(false);
const [loadingSessions, setLoadingSessions] = useState({});
const [additionalSessions, setAdditionalSessions] = useState({});
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
@@ -76,10 +76,6 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = useState('');
- const [showPathDropdown, setShowPathDropdown] = useState(false);
- const [pathList, setPathList] = useState([]);
- const [filteredPaths, setFilteredPaths] = useState([]);
- const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
// TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -184,123 +180,6 @@ function Sidebar({
};
}, []);
- // Load available paths for suggestions
- useEffect(() => {
- const loadPaths = async () => {
- try {
- // Get recent paths from localStorage
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
-
- // Load common/home directory paths
- const response = await api.browseFilesystem();
- const data = await response.json();
-
- if (data.suggestions) {
- const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
- const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
- setPathList(allPaths);
- } else {
- setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
- }
- } catch (error) {
- console.error('Error loading paths:', error);
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
- setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
- }
- };
-
- loadPaths();
- }, []);
-
- // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
- useEffect(() => {
- const inputValue = newProjectPath.trim();
-
- if (inputValue.length === 0) {
- setShowPathDropdown(false);
- return;
- }
-
- // Show dropdown when user starts typing
- setShowPathDropdown(true);
-
- const updateSuggestions = async () => {
- // First show filtered existing suggestions from pathList
- const staticFiltered = pathList.filter(pathItem =>
- pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
- pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
- );
-
- // Check if input looks like a directory path for dynamic browsing
- const isDirPath = inputValue.includes('/') && inputValue.length > 1;
-
- if (isDirPath) {
- try {
- let dirToSearch;
-
- // Determine which directory to search
- if (inputValue.endsWith('/')) {
- // User typed "/home/simos/" - search inside /home/simos
- dirToSearch = inputValue.slice(0, -1);
- } else {
- // User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
- const lastSlashIndex = inputValue.lastIndexOf('/');
- dirToSearch = inputValue.substring(0, lastSlashIndex);
- }
-
- // Only search if we have a valid directory path (not root only)
- if (dirToSearch && dirToSearch !== '') {
- const response = await api.browseFilesystem(dirToSearch);
- const data = await response.json();
-
- if (data.suggestions) {
- // Filter directories that match the current input
- const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
- const dynamicPaths = data.suggestions
- .filter(suggestion => {
- const dirName = suggestion.name;
- return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
- })
- .map(s => ({ name: s.name, path: s.path }))
- .slice(0, 8);
-
- // Combine static and dynamic suggestions, prioritize dynamic
- const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
- setFilteredPaths(combined);
- setSelectedPathIndex(-1);
- return;
- }
- }
- } catch (error) {
- console.debug('Dynamic browsing failed:', error.message);
- }
- }
-
- // Fallback to just static filtered suggestions
- setFilteredPaths(staticFiltered.slice(0, 8));
- setSelectedPathIndex(-1);
- };
-
- updateSuggestions();
- }, [newProjectPath, pathList]);
-
- // Select path from dropdown (ChatInterface pattern)
- const selectPath = (pathItem) => {
- setNewProjectPath(pathItem.path);
- setShowPathDropdown(false);
- setSelectedPathIndex(-1);
- };
-
- // Save path to recent paths
- const saveToRecentPaths = (path) => {
- try {
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
- const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
- localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
- } catch (error) {
- console.error('Error saving recent paths:', error);
- }
- };
const toggleProject = (projectName) => {
const newExpanded = new Set();
@@ -569,10 +448,27 @@ function Sidebar({
};
return (
-
+ <>
+ {/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
+ {showNewProject && ReactDOM.createPortal(
+
setShowNewProject(false)}
+ onProjectCreated={(project) => {
+ // Refresh projects list after creation
+ if (window.refreshProjects) {
+ window.refreshProjects();
+ } else {
+ window.location.reload();
+ }
+ }}
+ />,
+ document.body
+ )}
+
+
{/* Header */}
{/* Desktop Header */}
@@ -646,263 +542,7 @@ function Sidebar({
-
- {/* New Project Form */}
- {showNewProject && (
-
- {/* Desktop Form */}
-
-
-
- Create New Project
-
-
-
setNewProjectPath(e.target.value)}
- placeholder="/path/to/project or relative/path"
- className="text-sm focus:ring-2 focus:ring-primary/20"
- autoFocus
- onKeyDown={(e) => {
- // Handle path dropdown navigation (ChatInterface pattern)
- if (showPathDropdown && filteredPaths.length > 0) {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setSelectedPathIndex(prev =>
- prev < filteredPaths.length - 1 ? prev + 1 : 0
- );
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- setSelectedPathIndex(prev =>
- prev > 0 ? prev - 1 : filteredPaths.length - 1
- );
- } else if (e.key === 'Enter') {
- e.preventDefault();
- if (selectedPathIndex >= 0) {
- selectPath(filteredPaths[selectedPathIndex]);
- } else if (filteredPaths.length > 0) {
- selectPath(filteredPaths[0]);
- } else {
- createNewProject();
- }
- return;
- } else if (e.key === 'Escape') {
- e.preventDefault();
- setShowPathDropdown(false);
- return;
- } else if (e.key === 'Tab') {
- e.preventDefault();
- if (selectedPathIndex >= 0) {
- selectPath(filteredPaths[selectedPathIndex]);
- } else if (filteredPaths.length > 0) {
- selectPath(filteredPaths[0]);
- }
- return;
- }
- }
-
- // Regular input handling
- if (e.key === 'Enter') {
- createNewProject();
- }
- if (e.key === 'Escape') {
- cancelNewProject();
- }
- }}
- />
-
- {/* Path dropdown (ChatInterface pattern) */}
- {showPathDropdown && filteredPaths.length > 0 && (
-
- {filteredPaths.map((pathItem, index) => (
-
{
- e.preventDefault();
- e.stopPropagation();
- }}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- selectPath(pathItem);
- }}
- >
-
-
-
-
{pathItem.name}
-
- {pathItem.path}
-
-
-
-
- ))}
-
- )}
-
-
-
- {creatingProject ? 'Creating...' : 'Create Project'}
-
-
- Cancel
-
-
-
-
- {/* Mobile Form - Simple Overlay */}
-
-
-
-
-
-
-
-
-
New Project
-
-
-
-
-
-
-
-
-
-
setNewProjectPath(e.target.value)}
- placeholder="/path/to/project or relative/path"
- className="text-sm h-10 rounded-md focus:border-primary transition-colors"
- autoFocus
- onKeyDown={(e) => {
- // Handle path dropdown navigation (same as desktop)
- if (showPathDropdown && filteredPaths.length > 0) {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setSelectedPathIndex(prev =>
- prev < filteredPaths.length - 1 ? prev + 1 : 0
- );
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- setSelectedPathIndex(prev =>
- prev > 0 ? prev - 1 : filteredPaths.length - 1
- );
- } else if (e.key === 'Enter') {
- e.preventDefault();
- if (selectedPathIndex >= 0) {
- selectPath(filteredPaths[selectedPathIndex]);
- } else if (filteredPaths.length > 0) {
- selectPath(filteredPaths[0]);
- } else {
- createNewProject();
- }
- return;
- } else if (e.key === 'Escape') {
- e.preventDefault();
- setShowPathDropdown(false);
- return;
- }
- }
-
- // Regular input handling
- if (e.key === 'Enter') {
- createNewProject();
- }
- if (e.key === 'Escape') {
- cancelNewProject();
- }
- }}
- style={{
- fontSize: '16px', // Prevents zoom on iOS
- WebkitAppearance: 'none'
- }}
- />
-
- {/* Mobile Path dropdown */}
- {showPathDropdown && filteredPaths.length > 0 && (
-
- {filteredPaths.map((pathItem, index) => (
-
{
- e.preventDefault();
- e.stopPropagation();
- }}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- selectPath(pathItem);
- }}
- >
-
-
-
-
{pathItem.name}
-
- {pathItem.path}
-
-
-
-
- ))}
-
- )}
-
-
-
-
- Cancel
-
-
- {creatingProject ? 'Creating...' : 'Create'}
-
-
-
-
- {/* Safe area for mobile */}
-
-
-
-
- )}
-
{/* Search Filter and Actions */}
{projects.length > 0 && !isLoading && (
@@ -1691,6 +1331,7 @@ function Sidebar({
+ >
);
}
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx
index 77acb6c..c227814 100644
--- a/src/contexts/AuthContext.jsx
+++ b/src/contexts/AuthContext.jsx
@@ -34,12 +34,14 @@ export const AuthProvider = ({ children }) => {
const checkAuthStatus = async () => {
try {
+ console.log('[AuthContext] Checking auth status...');
setIsLoading(true);
setError(null);
-
+
// Check if system needs setup
const statusResponse = await api.auth.status();
const statusData = await statusResponse.json();
+ console.log('[AuthContext] Status response:', statusData);
if (statusData.needsSetup) {
setNeedsSetup(true);
@@ -70,9 +72,10 @@ export const AuthProvider = ({ children }) => {
}
}
} catch (error) {
- console.error('Auth status check failed:', error);
+ console.error('[AuthContext] Auth status check failed:', error);
setError('Failed to check authentication status');
} finally {
+ console.log('[AuthContext] Auth check complete, isLoading=false');
setIsLoading(false);
}
};
diff --git a/src/index.css b/src/index.css
index d47186a..90abc99 100644
--- a/src/index.css
+++ b/src/index.css
@@ -79,7 +79,7 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
+ --input: 220 13% 46%;
--ring: 217.2 91.2% 59.8%;
}
}
diff --git a/src/utils/api.js b/src/utils/api.js
index e3a93d3..9459417 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -71,6 +71,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ path }),
}),
+ createWorkspace: (workspaceData) =>
+ authenticatedFetch('/api/projects/create-workspace', {
+ method: 'POST',
+ body: JSON.stringify(workspaceData),
+ }),
readFile: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) =>