feat(projects): add project creation wizard with enhanced UX

Add new project creation wizard component with improved user experience
and better input field interactions. Integrate projects API routes on
the backend to support CRUD operations for project management.

Changes include:
- Add ProjectCreationWizard component with step-by-step project setup
- Improve input hint visibility to hide when user starts typing
- Refactor project creation state management in Sidebar component
- Add ReactDOM import for portal-based wizard rendering

The wizard provides a more intuitive onboarding experience for users
creating new projects, while the enhanced input hints reduce visual
clutter during active typing.
This commit is contained in:
simos
2025-11-04 08:26:31 +00:00
parent c7dbab086b
commit 0181883c8a
8 changed files with 828 additions and 432 deletions

View File

@@ -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();
@@ -465,50 +344,6 @@ function Sidebar({
}
};
const createNewProject = async () => {
if (!newProjectPath.trim()) {
alert('Please enter a project path');
return;
}
setCreatingProject(true);
try {
const response = await api.createProject(newProjectPath.trim());
if (response.ok) {
const result = await response.json();
// Save the path to recent paths before clearing
saveToRecentPaths(newProjectPath.trim());
setShowNewProject(false);
setNewProjectPath('');
setShowSuggestions(false);
// Refresh projects to show the new one
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
} else {
const error = await response.json();
alert(error.error || 'Failed to create project. Please try again.');
}
} catch (error) {
console.error('Error creating project:', error);
alert('Error creating project. Please try again.');
} finally {
setCreatingProject(false);
}
};
const cancelNewProject = () => {
setShowNewProject(false);
setNewProjectPath('');
setShowSuggestions(false);
};
const loadMoreSessions = async (project) => {
// Check if we can load more sessions
@@ -571,10 +406,27 @@ function Sidebar({
};
return (
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
>
<>
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
{showNewProject && ReactDOM.createPortal(
<ProjectCreationWizard
onClose={() => setShowNewProject(false)}
onProjectCreated={(project) => {
// Refresh projects list after creation
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
}}
/>,
document.body
)}
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
>
{/* Header */}
<div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */}
@@ -648,263 +500,7 @@ function Sidebar({
</div>
</div>
</div>
{/* New Project Form */}
{showNewProject && (
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
{/* Desktop Form */}
<div className="hidden md:block space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<FolderPlus className="w-4 h-4" />
Create New Project
</div>
<div className="relative">
<Input
value={newProjectPath}
onChange={(e) => 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 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{filteredPaths.map((pathItem, index) => (
<div
key={pathItem.path}
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
index === selectedPathIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectPath(pathItem);
}}
>
<div className="flex items-center gap-2">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<div>
<div className="font-medium text-sm">{pathItem.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{pathItem.path}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
>
{creatingProject ? 'Creating...' : 'Create Project'}
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelNewProject}
disabled={creatingProject}
className="h-8 text-xs hover:bg-accent transition-colors"
>
Cancel
</Button>
</div>
</div>
{/* Mobile Form - Simple Overlay */}
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
<FolderPlus className="w-3 h-3 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">New Project</h2>
</div>
</div>
<button
onClick={cancelNewProject}
disabled={creatingProject}
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="space-y-3">
<div className="relative">
<Input
value={newProjectPath}
onChange={(e) => 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 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
{filteredPaths.map((pathItem, index) => (
<div
key={pathItem.path}
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
index === selectedPathIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectPath(pathItem);
}}
>
<div className="flex items-center gap-2">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<div>
<div className="font-medium text-sm">{pathItem.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{pathItem.path}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
onClick={cancelNewProject}
disabled={creatingProject}
variant="outline"
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
>
Cancel
</Button>
<Button
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
>
{creatingProject ? 'Creating...' : 'Create'}
</Button>
</div>
</div>
{/* Safe area for mobile */}
<div className="h-4" />
</div>
</div>
</div>
)}
{/* Search Filter and Actions */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
@@ -1693,6 +1289,7 @@ function Sidebar({
</Button>
</div>
</div>
</>
);
}