Merge branch 'main' into feat/math-rendering

This commit is contained in:
viper151
2025-11-04 10:42:56 +01:00
committed by GitHub
9 changed files with 1000 additions and 396 deletions

View File

@@ -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"
>
<div className="flex-1">
<div className="font-medium">{token.token_name}</div>
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(token.created_at).toLocaleDateString()}
</div>

View File

@@ -4744,13 +4744,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</button>
{/* Hint text inside input box at bottom */}
<div className="absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block">
<div className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}>
{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"}
</div>
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
isInputFocused && !input.trim() ? 'opacity-100' : 'opacity-0'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send • Tab for modes • / for commands"

View File

@@ -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 (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Create New Project
</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
disabled={isCreating}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Indicator */}
<div className="px-6 pt-4 pb-2">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
s < step
? 'bg-green-500 text-white'
: s === step
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{s < step ? <Check className="w-4 h-4" /> : s}
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
</span>
</div>
{s < 3 && (
<div
className={`flex-1 h-1 mx-2 rounded ${
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6 min-h-[300px]">
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Step 1: Choose workspace type */}
{step === 1 && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Do you already have a workspace, or would you like to create a new one?
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */}
<button
onClick={() => 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'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
Existing Workspace
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
I already have a workspace on my server and just need to add it to the project list
</p>
</div>
</div>
</button>
{/* New Workspace */}
<button
onClick={() => 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'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
New Workspace
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Create a new workspace, optionally clone from a GitHub repository
</p>
</div>
</div>
</button>
</div>
</div>
</div>
)}
{/* Step 2: Configure workspace */}
{step === 2 && (
<div className="space-y-4">
{/* Workspace Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
</label>
<div className="relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? 'Full path to your existing workspace directory'
: 'Full path where the new workspace will be created'}
</p>
</div>
{/* GitHub URL (only for new workspace) */}
{workspaceType === 'new' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub URL (Optional)
</label>
<Input
type="text"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Leave empty to create an empty workspace, or provide a GitHub URL to clone
</p>
</div>
{/* GitHub Token (only if GitHub URL is provided) */}
{githubUrl && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
GitHub Authentication (Optional)
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Only required for private repositories. Public repos can be cloned without authentication.
</p>
</div>
</div>
{loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading stored tokens...
</div>
) : availableTokens.length > 0 ? (
<>
{/* Token Selection Tabs */}
<div className="grid grid-cols-3 gap-2 mb-4">
<button
onClick={() => 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
</button>
<button
onClick={() => 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
</button>
<button
onClick={() => {
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)
</button>
</div>
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Token
</label>
<select
value={selectedGithubToken}
onChange={(e) => 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"
>
<option value="">-- Select a token --</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.credential_name}
</option>
))}
</select>
</div>
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
This token will be used only for this operation
</p>
</div>
) : null}
</>
) : (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token (Optional for Public Repos)
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
</p>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Review Your Configuration
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
<span className="font-medium text-gray-900 dark:text-white">
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Path:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath}
</span>
</div>
{workspaceType === 'new' && githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
<span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? 'Using provided token'
: 'No authentication'}
</span>
</div>
</>
)}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
{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.'}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={step === 1 ? onClose : handleBack}
disabled={isCreating}
>
{step === 1 ? (
'Cancel'
) : (
<>
<ChevronLeft className="w-4 h-4 mr-1" />
Back
</>
)}
</Button>
<Button
onClick={step === 3 ? handleCreate : handleNext}
disabled={isCreating || (step === 1 && !workspaceType)}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : step === 3 ? (
<>
<Check className="w-4 h-4 mr-1" />
Create Project
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</div>
);
};
export default ProjectCreationWizard;

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();
@@ -569,10 +448,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 */}
@@ -646,263 +542,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">
@@ -1691,6 +1331,7 @@ function Sidebar({
</Button>
</div>
</div>
</>
);
}