diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs index 2d1c7e36..3275487f 100644 --- a/src-tauri/src/claude_binary.rs +++ b/src-tauri/src/claude_binary.rs @@ -519,3 +519,80 @@ pub fn create_command_with_env(program: &str) -> Command { cmd } + +/// Helper function to create a Command with proper environment variables and profile config directory +/// This ensures commands like Claude can find Node.js and other dependencies, and use the correct profile +pub fn create_command_with_env_and_profile(program: &str, config_directory: &str) -> tokio::process::Command { + let mut cmd = tokio::process::Command::new(program); + + info!("Creating command for: {} with profile config: {}", program, config_directory); + + // Inherit essential environment variables from parent process + for (key, value) in std::env::vars() { + // Pass through PATH and other essential environment variables + if key == "PATH" + || key == "HOME" + || key == "USER" + || key == "SHELL" + || key == "LANG" + || key == "LC_ALL" + || key.starts_with("LC_") + || key == "NODE_PATH" + || key == "NVM_DIR" + || key == "NVM_BIN" + || key == "HOMEBREW_PREFIX" + || key == "HOMEBREW_CELLAR" + // Add proxy environment variables (only uppercase) + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "NO_PROXY" + || key == "ALL_PROXY" + { + debug!("Inheriting env var: {}={}", key, value); + cmd.env(&key, &value); + } + } + + // Set the CLAUDE_CONFIG_DIR environment variable for the profile + cmd.env("CLAUDE_CONFIG_DIR", config_directory); + info!("Set CLAUDE_CONFIG_DIR={}", config_directory); + + // Log proxy-related environment variables for debugging + info!("Command will use proxy settings:"); + if let Ok(http_proxy) = std::env::var("HTTP_PROXY") { + info!(" HTTP_PROXY={}", http_proxy); + } + if let Ok(https_proxy) = std::env::var("HTTPS_PROXY") { + info!(" HTTPS_PROXY={}", https_proxy); + } + + // Add NVM support if the program is in an NVM directory + if program.contains("/.nvm/versions/node/") { + if let Some(node_bin_dir) = std::path::Path::new(program).parent() { + // Ensure the Node.js bin directory is in PATH + let current_path = std::env::var("PATH").unwrap_or_default(); + let node_bin_str = node_bin_dir.to_string_lossy(); + if !current_path.contains(&node_bin_str.as_ref()) { + let new_path = format!("{}:{}", node_bin_str, current_path); + debug!("Adding NVM bin directory to PATH: {}", node_bin_str); + cmd.env("PATH", new_path); + } + } + } + + // Add Homebrew support if the program is in a Homebrew directory + if program.contains("/homebrew/") || program.contains("/opt/homebrew/") { + if let Some(program_dir) = std::path::Path::new(program).parent() { + // Ensure the Homebrew bin directory is in PATH + let current_path = std::env::var("PATH").unwrap_or_default(); + let homebrew_bin_str = program_dir.to_string_lossy(); + if !current_path.contains(&homebrew_bin_str.as_ref()) { + let new_path = format!("{}:{}", homebrew_bin_str, current_path); + debug!("Adding Homebrew bin directory to PATH: {}", homebrew_bin_str); + cmd.env("PATH", new_path); + } + } + } + + cmd +} diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index b988ce71..99ca446c 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -92,6 +92,27 @@ pub struct AgentData { pub hooks: Option, } +/// Represents a Claude profile for managing different configurations +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ClaudeProfile { + pub id: Option, + pub name: String, + pub config_directory: String, + pub description: Option, + pub is_default: bool, + pub created_at: String, + pub updated_at: String, +} + +/// Request structure for creating or updating a Claude profile +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateProfileRequest { + pub name: String, + pub config_directory: String, + pub description: Option, + pub is_default: bool, +} + /// Database connection state pub struct AgentDb(pub Mutex); @@ -340,6 +361,63 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { [], )?; + // Create claude_profiles table for managing multiple Claude configurations + conn.execute( + "CREATE TABLE IF NOT EXISTS claude_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + config_directory TEXT NOT NULL UNIQUE, + description TEXT, + is_default BOOLEAN NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + // Create trigger to update the updated_at timestamp for profiles + conn.execute( + "CREATE TRIGGER IF NOT EXISTS update_claude_profiles_timestamp + AFTER UPDATE ON claude_profiles + FOR EACH ROW + BEGIN + UPDATE claude_profiles SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END", + [], + )?; + + // Migration: Create default profile if none exist and ~/.claude directory exists + let profiles_exist = conn.query_row( + "SELECT COUNT(*) FROM claude_profiles", + [], + |row| row.get::<_, i64>(0), + ).unwrap_or(0) > 0; + + if !profiles_exist { + // Check if ~/.claude directory exists + if let Some(home_dir) = dirs::home_dir() { + let claude_dir = home_dir.join(".claude"); + if claude_dir.exists() { + info!("Migrating existing ~/.claude directory to default profile"); + let claude_dir_str = claude_dir.to_string_lossy().to_string(); + let _ = conn.execute( + "INSERT INTO claude_profiles (name, config_directory, description, is_default) + VALUES ('Personal', ?1, 'Default profile migrated from existing configuration', 1)", + params![claude_dir_str], + ); + } else { + // Create a default profile pointing to ~/.claude even if it doesn't exist yet + info!("Creating default profile for ~/.claude directory"); + let claude_dir_str = claude_dir.to_string_lossy().to_string(); + let _ = conn.execute( + "INSERT INTO claude_profiles (name, config_directory, description, is_default) + VALUES ('Personal', ?1, 'Default profile for personal projects', 1)", + params![claude_dir_str], + ); + } + } + } + Ok(conn) } @@ -1967,3 +2045,340 @@ pub async fn load_agent_session_history( Err(format!("Session file not found: {}", session_id)) } } + +// ============================================================================ +// Claude Profile Management Commands +// ============================================================================ + +/// List all Claude profiles +#[tauri::command] +pub async fn list_claude_profiles(db: State<'_, AgentDb>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let mut stmt = conn + .prepare("SELECT id, name, config_directory, description, is_default, created_at, updated_at FROM claude_profiles ORDER BY is_default DESC, name ASC") + .map_err(|e| e.to_string())?; + + let profile_iter = stmt + .query_map([], |row| { + Ok(ClaudeProfile { + id: Some(row.get(0)?), + name: row.get(1)?, + config_directory: row.get(2)?, + description: row.get(3)?, + is_default: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| e.to_string())?; + + let mut profiles = Vec::new(); + for profile in profile_iter { + profiles.push(profile.map_err(|e| e.to_string())?); + } + + Ok(profiles) +} + +/// Create a new Claude profile +#[tauri::command] +pub async fn create_claude_profile( + db: State<'_, AgentDb>, + request: CreateProfileRequest, +) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // Validate the profile directory + let expanded_dir = validate_and_expand_directory(&request.config_directory)?; + + // If this should be the default, unset any existing default + if request.is_default { + let _ = conn.execute( + "UPDATE claude_profiles SET is_default = 0", + [], + ); + } + + // Insert the new profile + let result = conn.execute( + "INSERT INTO claude_profiles (name, config_directory, description, is_default) + VALUES (?1, ?2, ?3, ?4)", + params![ + request.name, + expanded_dir, + request.description, + request.is_default + ], + ); + + match result { + Ok(_) => { + let profile_id = conn.last_insert_rowid(); + + // Fetch and return the created profile + let mut stmt = conn + .prepare("SELECT id, name, config_directory, description, is_default, created_at, updated_at FROM claude_profiles WHERE id = ?1") + .map_err(|e| e.to_string())?; + + let profile = stmt + .query_row([profile_id], |row| { + Ok(ClaudeProfile { + id: Some(row.get(0)?), + name: row.get(1)?, + config_directory: row.get(2)?, + description: row.get(3)?, + is_default: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| e.to_string())?; + + Ok(profile) + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE { + Err("A profile with this name or directory already exists".to_string()) + } else { + Err("Constraint violation when creating profile".to_string()) + } + } + Err(e) => Err(format!("Failed to create profile: {}", e)), + } +} + +/// Update an existing Claude profile +#[tauri::command] +pub async fn update_claude_profile( + db: State<'_, AgentDb>, + id: i64, + request: CreateProfileRequest, +) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // Validate the profile directory + let expanded_dir = validate_and_expand_directory(&request.config_directory)?; + + // If this should be the default, unset any existing default + if request.is_default { + let _ = conn.execute( + "UPDATE claude_profiles SET is_default = 0 WHERE id != ?1", + params![id], + ); + } + + // Update the profile + let result = conn.execute( + "UPDATE claude_profiles + SET name = ?1, config_directory = ?2, description = ?3, is_default = ?4 + WHERE id = ?5", + params![ + request.name, + expanded_dir, + request.description, + request.is_default, + id + ], + ); + + match result { + Ok(rows_affected) => { + if rows_affected == 0 { + return Err("Profile not found".to_string()); + } + + // Fetch and return the updated profile + let mut stmt = conn + .prepare("SELECT id, name, config_directory, description, is_default, created_at, updated_at FROM claude_profiles WHERE id = ?1") + .map_err(|e| e.to_string())?; + + let profile = stmt + .query_row([id], |row| { + Ok(ClaudeProfile { + id: Some(row.get(0)?), + name: row.get(1)?, + config_directory: row.get(2)?, + description: row.get(3)?, + is_default: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| e.to_string())?; + + Ok(profile) + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE { + Err("A profile with this name or directory already exists".to_string()) + } else { + Err("Constraint violation when updating profile".to_string()) + } + } + Err(e) => Err(format!("Failed to update profile: {}", e)), + } +} + +/// Delete a Claude profile +#[tauri::command] +pub async fn delete_claude_profile(db: State<'_, AgentDb>, id: i64) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // Check if this is the default profile + let is_default = conn + .query_row( + "SELECT is_default FROM claude_profiles WHERE id = ?1", + params![id], + |row| row.get::<_, bool>(0), + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => "Profile not found".to_string(), + _ => e.to_string(), + })?; + + // Check if this is the only profile + let profile_count = conn + .query_row( + "SELECT COUNT(*) FROM claude_profiles", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + + if profile_count <= 1 { + return Err("Cannot delete the last profile. At least one profile must exist.".to_string()); + } + + // Delete the profile + let result = conn.execute( + "DELETE FROM claude_profiles WHERE id = ?1", + params![id], + ); + + match result { + Ok(rows_affected) => { + if rows_affected == 0 { + return Err("Profile not found".to_string()); + } + + // If we deleted the default profile, set another one as default + if is_default { + let _ = conn.execute( + "UPDATE claude_profiles SET is_default = 1 WHERE id = (SELECT id FROM claude_profiles ORDER BY created_at ASC LIMIT 1)", + [], + ); + } + + Ok("Profile deleted successfully".to_string()) + } + Err(e) => Err(format!("Failed to delete profile: {}", e)), + } +} + +/// Get the default Claude profile +#[tauri::command] +pub async fn get_default_profile(db: State<'_, AgentDb>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let mut stmt = conn + .prepare("SELECT id, name, config_directory, description, is_default, created_at, updated_at FROM claude_profiles WHERE is_default = 1 LIMIT 1") + .map_err(|e| e.to_string())?; + + match stmt.query_row([], |row| { + Ok(ClaudeProfile { + id: Some(row.get(0)?), + name: row.get(1)?, + config_directory: row.get(2)?, + description: row.get(3)?, + is_default: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) { + Ok(profile) => Ok(Some(profile)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +/// Set a profile as the default +#[tauri::command] +pub async fn set_default_profile(db: State<'_, AgentDb>, id: i64) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // First, unset all defaults + conn.execute("UPDATE claude_profiles SET is_default = 0", []) + .map_err(|e| e.to_string())?; + + // Then set the specified profile as default + let result = conn.execute( + "UPDATE claude_profiles SET is_default = 1 WHERE id = ?1", + params![id], + ); + + match result { + Ok(rows_affected) => { + if rows_affected == 0 { + Err("Profile not found".to_string()) + } else { + Ok("Default profile updated successfully".to_string()) + } + } + Err(e) => Err(format!("Failed to set default profile: {}", e)), + } +} + +/// Validate profile directory path +#[tauri::command] +pub async fn validate_profile_directory(path: String) -> Result { + match validate_and_expand_directory(&path) { + Ok(_) => Ok(true), + Err(e) => Err(e), + } +} + +/// Helper function to validate and expand directory paths +fn validate_and_expand_directory(path: &str) -> Result { + // Expand tilde to home directory + let expanded_path = if path.starts_with("~/") { + match dirs::home_dir() { + Some(home) => home.join(&path[2..]).to_string_lossy().to_string(), + None => return Err("Could not determine home directory".to_string()), + } + } else if path == "~" { + match dirs::home_dir() { + Some(home) => home.to_string_lossy().to_string(), + None => return Err("Could not determine home directory".to_string()), + } + } else { + path.to_string() + }; + + // Validate the path is reasonable (not trying to escape user space) + let path_buf = std::path::PathBuf::from(&expanded_path); + + // Check if path is absolute + if !path_buf.is_absolute() { + return Err("Profile directory must be an absolute path".to_string()); + } + + // Check if we can create the directory (don't actually create it, just validate permissions) + if let Some(parent) = path_buf.parent() { + if parent.exists() { + // Check if parent is writable + match std::fs::metadata(parent) { + Ok(_) => { + // Directory looks valid, return the expanded path + Ok(expanded_path) + } + Err(e) => Err(format!("Cannot access parent directory: {}", e)), + } + } else { + // Parent doesn't exist, check if we can create it + Err("Parent directory does not exist. Please create it first or choose an existing directory.".to_string()) + } + } else { + Err("Invalid directory path".to_string()) + } +} diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 94ad3c55..5d0e19ee 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -6,9 +6,10 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; +use crate::commands::agents::{AgentDb, ClaudeProfile}; /// Global state to track current Claude process @@ -135,6 +136,11 @@ fn find_claude_binary(app_handle: &AppHandle) -> Result { crate::claude_binary::find_claude_binary(app_handle) } +/// Creates a tokio Command with proper environment variables and profile config directory +fn create_command_with_env_and_profile(program: &str, config_directory: &str) -> Command { + crate::claude_binary::create_command_with_env_and_profile(program, config_directory) +} + /// Gets the path to the ~/.claude directory fn get_claude_dir() -> Result { dirs::home_dir() @@ -2156,3 +2162,168 @@ pub async fn validate_hook_command(command: String) -> Result Err(format!("Failed to validate command: {}", e)) } } + +// ============================================================================ +// Profile-Aware Session Creation Functions +// ============================================================================ + +/// Execute a new Claude Code session with a specific profile +#[tauri::command] +pub async fn execute_claude_code_with_profile( + app: AppHandle, + db: State<'_, AgentDb>, + profile_id: i64, + project_path: String, + prompt: String, + model: String, +) -> Result<(), String> { + // Get the profile information + let profile = get_profile_by_id(db, profile_id).await?; + + log::info!( + "Starting new Claude Code session with profile '{}' ({}) in: {} with model: {}", + profile.name, + profile.config_directory, + project_path, + model + ); + + let claude_path = find_claude_binary(&app)?; + + let args = vec![ + "-p".to_string(), + prompt.clone(), + "--model".to_string(), + model.clone(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + ]; + let cmd = create_system_command_with_profile(&claude_path, args, &project_path, &profile.config_directory); + spawn_claude_process(app, cmd, prompt, model, project_path).await +} + +/// Continue an existing Claude Code conversation with a specific profile +#[tauri::command] +pub async fn continue_claude_code_with_profile( + app: AppHandle, + db: State<'_, AgentDb>, + profile_id: i64, + project_path: String, + prompt: String, + model: String, +) -> Result<(), String> { + // Get the profile information + let profile = get_profile_by_id(db, profile_id).await?; + + log::info!( + "Continuing Claude Code conversation with profile '{}' ({}) in: {} with model: {}", + profile.name, + profile.config_directory, + project_path, + model + ); + + let claude_path = find_claude_binary(&app)?; + + let args = vec![ + "-c".to_string(), + prompt.clone(), + "--model".to_string(), + model.clone(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + ]; + let cmd = create_system_command_with_profile(&claude_path, args, &project_path, &profile.config_directory); + spawn_claude_process(app, cmd, prompt, model, project_path).await +} + +/// Resume an existing Claude Code session by ID with a specific profile +#[tauri::command] +pub async fn resume_claude_code_with_profile( + app: AppHandle, + db: State<'_, AgentDb>, + profile_id: i64, + project_path: String, + session_id: String, + prompt: String, + model: String, +) -> Result<(), String> { + // Get the profile information + let profile = get_profile_by_id(db, profile_id).await?; + + log::info!( + "Resuming Claude Code session: {} with profile '{}' ({}) in: {} with model: {}", + session_id, + profile.name, + profile.config_directory, + project_path, + model + ); + + let claude_path = find_claude_binary(&app)?; + + let args = vec![ + "--session-id".to_string(), + session_id.clone(), + "-c".to_string(), + prompt.clone(), + "--model".to_string(), + model.clone(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + ]; + let cmd = create_system_command_with_profile(&claude_path, args, &project_path, &profile.config_directory); + spawn_claude_process(app, cmd, prompt, model, project_path).await +} + +/// Helper function to get a profile by ID +async fn get_profile_by_id(db: State<'_, AgentDb>, profile_id: i64) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let mut stmt = conn + .prepare("SELECT id, name, config_directory, description, is_default, created_at, updated_at FROM claude_profiles WHERE id = ?1") + .map_err(|e| e.to_string())?; + + stmt.query_row([profile_id], |row| { + Ok(ClaudeProfile { + id: Some(row.get(0)?), + name: row.get(1)?, + config_directory: row.get(2)?, + description: row.get(3)?, + is_default: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => "Profile not found".to_string(), + _ => e.to_string(), + }) +} + +/// Creates a system binary command with the given arguments and profile config directory +fn create_system_command_with_profile( + claude_path: &str, + args: Vec, + project_path: &str, + config_directory: &str, +) -> Command { + let mut cmd = create_command_with_env_and_profile(claude_path, config_directory); + + // Add all arguments + for arg in args { + cmd.arg(arg); + } + + cmd.current_dir(project_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + cmd +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ffc0212e..b0c15515 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,7 +14,11 @@ use commands::agents::{ get_live_session_output, get_session_output, get_session_status, import_agent, import_agent_from_file, import_agent_from_github, init_database, kill_agent_session, list_agent_runs, list_agent_runs_with_metrics, list_agents, list_claude_installations, - list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, AgentDb, + list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, + // Profile management commands + list_claude_profiles, create_claude_profile, update_claude_profile, delete_claude_profile, + get_default_profile, set_default_profile, validate_profile_directory, + AgentDb, }; use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, @@ -27,6 +31,8 @@ use commands::claude::{ save_claude_md_file, save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, get_hooks_config, update_hooks_config, validate_hook_command, + // Profile-aware session creation commands + execute_claude_code_with_profile, continue_claude_code_with_profile, resume_claude_code_with_profile, ClaudeProcessState, }; use commands::mcp::{ @@ -284,6 +290,20 @@ fn main() { // Proxy Settings get_proxy_settings, save_proxy_settings, + + // Claude Profile Management + list_claude_profiles, + create_claude_profile, + update_claude_profile, + delete_claude_profile, + get_default_profile, + set_default_profile, + validate_profile_directory, + + // Profile-Aware Session Creation + execute_claude_code_with_profile, + continue_claude_code_with_profile, + resume_claude_code_with_profile, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index d4563e24..30a1d345 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -30,6 +30,7 @@ import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; import { SessionPersistenceService } from "@/services/sessionPersistence"; +import type { ClaudeProfile } from "@/lib/api"; interface ClaudeCodeSessionProps { /** @@ -40,6 +41,10 @@ interface ClaudeCodeSessionProps { * Initial project path (for new sessions) */ initialProjectPath?: string; + /** + * Pre-selected Claude profile for this session + */ + selectedProfile?: ClaudeProfile; /** * Callback to go back */ @@ -71,6 +76,7 @@ interface ClaudeCodeSessionProps { export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", + selectedProfile: initialSelectedProfile, className, onStreamingChange, onProjectPathChange, @@ -105,6 +111,10 @@ export const ClaudeCodeSession: React.FC = ({ // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); + + // Profile selection state + const [selectedProfile, setSelectedProfile] = useState(initialSelectedProfile || null); + const [defaultProfile, setDefaultProfile] = useState(null); const parentRef = useRef(null); const unlistenRefs = useRef([]); @@ -151,6 +161,24 @@ export const ClaudeCodeSession: React.FC = ({ queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); + // Load default profile on component mount + useEffect(() => { + const loadDefaultProfile = async () => { + try { + const profiles = await api.listClaudeProfiles(); + const defaultProf = profiles.find(p => p.is_default); + if (defaultProf) { + setDefaultProfile(defaultProf); + console.log('[ClaudeCodeSession] Loaded default profile:', defaultProf); + } + } catch (err) { + console.error('Failed to load default profile:', err); + } + }; + + loadDefaultProfile(); + }, []); + // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { if (session) return session; @@ -432,7 +460,7 @@ export const ClaudeCodeSession: React.FC = ({ const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); - + if (!projectPath) { setError("Please select a project directory first"); return; @@ -449,6 +477,12 @@ export const ClaudeCodeSession: React.FC = ({ return; } + // If this is a new session without a profile, we should have gotten one from the split dropdown + // This shouldn't happen now, but keeping as fallback + if (isFirstPrompt && !selectedProfile) { + console.warn("New session started without profile selection - this should not happen with the split dropdown"); + } + try { setIsLoading(true); setError(null); @@ -800,16 +834,28 @@ export const ClaudeCodeSession: React.FC = ({ // Execute the appropriate command if (effectiveSession && !isFirstPrompt) { - console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); trackEvent.sessionResumed(effectiveSession.id); trackEvent.modelSelected(model); - await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); + + // Always use profile-aware resume (selected profile or default profile) + const profileToUse = selectedProfile || defaultProfile; + if (profileToUse) { + await api.resumeClaudeCodeWithProfile(profileToUse.id!, projectPath, effectiveSession.id, prompt, model); + } else { + await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); + } } else { - console.log('[ClaudeCodeSession] Starting new session'); setIsFirstPrompt(false); trackEvent.sessionCreated(model, 'prompt_input'); trackEvent.modelSelected(model); - await api.executeClaudeCode(projectPath, prompt, model); + + // Always use profile-aware execution (selected profile or default profile) + const profileToUse = selectedProfile || defaultProfile; + if (profileToUse) { + await api.executeClaudeCodeWithProfile(profileToUse.id!, projectPath, prompt, model); + } else { + await api.executeClaudeCode(projectPath, prompt, model); + } } } } catch (err) { @@ -1085,6 +1131,7 @@ export const ClaudeCodeSession: React.FC = ({ } }; + // Cleanup event listeners and track mount state useEffect(() => { isMountedRef.current = true; @@ -1170,10 +1217,12 @@ export const ClaudeCodeSession: React.FC = ({ top: virtualItem.start, }} > - ); @@ -1666,6 +1715,7 @@ export const ClaudeCodeSession: React.FC = ({ )} + ); diff --git a/src/components/ClaudeProfileManager.tsx b/src/components/ClaudeProfileManager.tsx new file mode 100644 index 00000000..2972cd1a --- /dev/null +++ b/src/components/ClaudeProfileManager.tsx @@ -0,0 +1,491 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Textarea } from "./ui/textarea"; +import { Badge } from "./ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; +import { Checkbox } from "./ui/checkbox"; +import { Plus, Edit3, Trash2, FolderOpen, Star } from "lucide-react"; +import { api, ClaudeProfile, CreateProfileRequest } from "@/lib/api"; +import { Toast } from "./ui/toast"; + +interface ClaudeProfileManagerProps { + className?: string; +} + +export function ClaudeProfileManager({ className }: ClaudeProfileManagerProps) { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [editingProfile, setEditingProfile] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [profileToDelete, setProfileToDelete] = useState(null); + const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + + // Form state + const [formData, setFormData] = useState({ + name: "", + config_directory: "", + description: "", + is_default: false, + }); + + // Load profiles on component mount + useEffect(() => { + loadProfiles(); + }, []); + + const loadProfiles = async () => { + try { + setLoading(true); + const profileList = await api.listClaudeProfiles(); + setProfiles(profileList); + } catch (error) { + console.error("Failed to load profiles:", error); + setToast({ + message: "Failed to load Claude profiles", + type: "error", + }); + } finally { + setLoading(false); + } + }; + + const resetForm = () => { + setFormData({ + name: "", + config_directory: "", + description: "", + is_default: false, + }); + }; + + const handleCreateProfile = async () => { + try { + if (!formData.name.trim()) { + setToast({ + message: "Profile name is required", + type: "error", + }); + return; + } + + if (!formData.config_directory.trim()) { + setToast({ + message: "Configuration directory is required", + type: "error", + }); + return; + } + + // Validate directory path + await api.validateProfileDirectory(formData.config_directory); + + const newProfile = await api.createClaudeProfile(formData); + setProfiles(prev => [...prev, newProfile]); + setIsCreateDialogOpen(false); + resetForm(); + + setToast({ + message: "Profile created successfully", + type: "success", + }); + } catch (error: any) { + console.error("Failed to create profile:", error); + setToast({ + message: error.message || "Failed to create profile", + type: "error", + }); + } + }; + + const handleEditProfile = async () => { + if (!editingProfile?.id) return; + + try { + if (!formData.name.trim()) { + setToast({ + message: "Profile name is required", + type: "error", + }); + return; + } + + if (!formData.config_directory.trim()) { + setToast({ + message: "Configuration directory is required", + type: "error", + }); + return; + } + + // Validate directory path + await api.validateProfileDirectory(formData.config_directory); + + const updatedProfile = await api.updateClaudeProfile(editingProfile.id, formData); + setProfiles(prev => prev.map(p => p.id === editingProfile.id ? updatedProfile : p)); + setEditingProfile(null); + resetForm(); + + setToast({ + message: "Profile updated successfully", + type: "success", + }); + } catch (error: any) { + console.error("Failed to update profile:", error); + setToast({ + message: error.message || "Failed to update profile", + type: "error", + }); + } + }; + + const handleDeleteProfile = async () => { + if (!profileToDelete?.id) return; + + try { + await api.deleteClaudeProfile(profileToDelete.id); + setProfiles(prev => prev.filter(p => p.id !== profileToDelete.id)); + setProfileToDelete(null); + setShowDeleteDialog(false); + + setToast({ + message: "Profile deleted successfully", + type: "success", + }); + + // Reload profiles to ensure default profile is correctly set + loadProfiles(); + } catch (error: any) { + console.error("Failed to delete profile:", error); + setToast({ + message: error.message || "Failed to delete profile", + type: "error", + }); + } + }; + + const openDeleteDialog = (profile: ClaudeProfile) => { + setProfileToDelete(profile); + setShowDeleteDialog(true); + }; + + const handleSetDefault = async (profile: ClaudeProfile) => { + if (!profile.id) return; + + try { + await api.setDefaultProfile(profile.id); + setProfiles(prev => prev.map(p => ({ + ...p, + is_default: p.id === profile.id + }))); + + setToast({ + message: `"${profile.name}" is now the default profile`, + type: "success", + }); + } catch (error: any) { + console.error("Failed to set default profile:", error); + setToast({ + message: error.message || "Failed to set default profile", + type: "error", + }); + } + }; + + const openEditDialog = (profile: ClaudeProfile) => { + setEditingProfile(profile); + setFormData({ + name: profile.name, + config_directory: profile.config_directory, + description: profile.description || "", + is_default: profile.is_default, + }); + }; + + const expandPath = (path: string) => { + if (path.startsWith("~/")) { + return path; // Show the tilde version for user clarity + } + return path; + }; + + if (loading) { + return ( +
+
Loading profiles...
+
+ ); + } + + return ( +
+
+
+

Claude Profiles

+

+ Manage different Claude Code configuration directories +

+
+ + + + + + + + Create New Profile + + Add a new Claude configuration profile + + +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Personal, Work, Client-A" + /> +
+
+ + setFormData(prev => ({ ...prev, config_directory: e.target.value }))} + placeholder="e.g., ~/.claude-work" + /> +
+
+ +