diff --git a/.gitignore b/.gitignore index 46aa6e1..02de0b6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ dist-ssr *.sw? temp_lib/ -.cursor/ \ No newline at end of file +.cursor/ +AGENTS.md +CLAUDE.md +*_TASK.md \ No newline at end of file diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 754a4d5..3424cb5 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -9,7 +9,6 @@ use std::time::SystemTime; use tauri::{AppHandle, Emitter, Manager}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; -use uuid; /// Global state to track current Claude process pub struct ClaudeProcessState { @@ -821,7 +820,7 @@ pub async fn execute_claude_code( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - spawn_claude_process(app, cmd).await + spawn_claude_process(app, cmd, prompt, model, project_path).await } /// Continue an existing Claude Code conversation with streaming output @@ -861,7 +860,7 @@ pub async fn continue_claude_code( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - spawn_claude_process(app, cmd).await + spawn_claude_process(app, cmd, prompt, model, project_path).await } /// Resume an existing Claude Code session by ID with streaming output @@ -904,7 +903,7 @@ pub async fn resume_claude_code( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - spawn_claude_process(app, cmd).await + spawn_claude_process(app, cmd, prompt, model, project_path).await } /// Cancel the currently running Claude Code execution @@ -918,40 +917,83 @@ pub async fn cancel_claude_execution( session_id ); - let claude_state = app.state::(); - let mut current_process = claude_state.current_process.lock().await; - - if let Some(mut child) = current_process.take() { - // Try to get the PID before killing - let pid = child.id(); - log::info!("Attempting to kill Claude process with PID: {:?}", pid); - - // Kill the process - match child.kill().await { - Ok(_) => { - log::info!("Successfully killed Claude process"); - - // If we have a session ID, emit session-specific events - if let Some(sid) = session_id { - let _ = app.emit(&format!("claude-cancelled:{}", sid), true); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let _ = app.emit(&format!("claude-complete:{}", sid), false); + let killed = if let Some(sid) = &session_id { + // Try to find and kill via ProcessRegistry first + let registry = app.state::(); + if let Ok(Some(process_info)) = registry.0.get_claude_session_by_id(sid) { + match registry.0.kill_process(process_info.run_id).await { + Ok(success) => success, + Err(e) => { + log::warn!("Failed to kill via registry: {}", e); + false } - - // Also emit generic events for backward compatibility - let _ = app.emit("claude-cancelled", true); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let _ = app.emit("claude-complete", false); - Ok(()) - } - Err(e) => { - log::error!("Failed to kill Claude process: {}", e); - Err(format!("Failed to kill Claude process: {}", e)) } + } else { + false } } else { - log::warn!("No active Claude process to cancel"); - Ok(()) + false + }; + + // If registry kill didn't work, try the legacy approach + if !killed { + let claude_state = app.state::(); + let mut current_process = claude_state.current_process.lock().await; + + if let Some(mut child) = current_process.take() { + // Try to get the PID before killing + let pid = child.id(); + log::info!("Attempting to kill Claude process with PID: {:?}", pid); + + // Kill the process + match child.kill().await { + Ok(_) => { + log::info!("Successfully killed Claude process"); + } + Err(e) => { + log::error!("Failed to kill Claude process: {}", e); + return Err(format!("Failed to kill Claude process: {}", e)); + } + } + } else { + log::warn!("No active Claude process to cancel"); + } + } + + // Emit cancellation events + if let Some(sid) = session_id { + let _ = app.emit(&format!("claude-cancelled:{}", sid), true); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let _ = app.emit(&format!("claude-complete:{}", sid), false); + } + + // Also emit generic events for backward compatibility + let _ = app.emit("claude-cancelled", true); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let _ = app.emit("claude-complete", false); + + Ok(()) +} + +/// Get all running Claude sessions +#[tauri::command] +pub async fn list_running_claude_sessions( + registry: tauri::State<'_, crate::process::ProcessRegistryState>, +) -> Result, String> { + registry.0.get_running_claude_sessions() +} + +/// Get live output from a Claude session +#[tauri::command] +pub async fn get_claude_session_output( + registry: tauri::State<'_, crate::process::ProcessRegistryState>, + session_id: String, +) -> Result { + // Find the process by session ID + if let Some(process_info) = registry.0.get_claude_session_by_id(&session_id)? { + registry.0.get_live_output(process_info.run_id) + } else { + Ok(String::new()) } } @@ -1151,18 +1193,9 @@ fn get_claude_settings_sync(_app: &AppHandle) -> Result } /// Helper function to spawn Claude process and handle streaming -async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), String> { +async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, model: String, project_path: String) -> Result<(), String> { use tokio::io::{AsyncBufReadExt, BufReader}; - - // Generate a unique session ID for this Claude Code session - let session_id = format!( - "claude-{}-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - uuid::Uuid::new_v4().to_string() - ); + use std::sync::Mutex; // Spawn the process let mut child = cmd @@ -1174,17 +1207,20 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St let stderr = child.stderr.take().ok_or("Failed to get stderr")?; // Get the child PID for logging - let pid = child.id(); + let pid = child.id().unwrap_or(0); log::info!( - "Spawned Claude process with PID: {:?} and session ID: {}", - pid, - session_id + "Spawned Claude process with PID: {:?}", + pid ); - // Create readers + // Create readers first (before moving child) let stdout_reader = BufReader::new(stdout); let stderr_reader = BufReader::new(stderr); + // We'll extract the session ID from Claude's init message + let session_id_holder: Arc>> = Arc::new(Mutex::new(None)); + let run_id_holder: Arc>> = Arc::new(Mutex::new(None)); + // Store the child process in the global state (for backward compatibility) let claude_state = app.state::(); { @@ -1199,26 +1235,73 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St // Spawn tasks to read stdout and stderr let app_handle = app.clone(); - let session_id_clone = session_id.clone(); + let session_id_holder_clone = session_id_holder.clone(); + let run_id_holder_clone = run_id_holder.clone(); + let registry = app.state::(); + let registry_clone = registry.0.clone(); + let project_path_clone = project_path.clone(); + let prompt_clone = prompt.clone(); + let model_clone = model.clone(); let stdout_task = tokio::spawn(async move { let mut lines = stdout_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { log::debug!("Claude stdout: {}", line); - // Emit the line to the frontend with session isolation - let _ = app_handle.emit(&format!("claude-output:{}", session_id_clone), &line); + + // Parse the line to check for init message with session ID + if let Ok(msg) = serde_json::from_str::(&line) { + if msg["type"] == "system" && msg["subtype"] == "init" { + if let Some(claude_session_id) = msg["session_id"].as_str() { + let mut session_id_guard = session_id_holder_clone.lock().unwrap(); + if session_id_guard.is_none() { + *session_id_guard = Some(claude_session_id.to_string()); + log::info!("Extracted Claude session ID: {}", claude_session_id); + + // Now register with ProcessRegistry using Claude's session ID + match registry_clone.register_claude_session( + claude_session_id.to_string(), + pid, + project_path_clone.clone(), + prompt_clone.clone(), + model_clone.clone(), + ) { + Ok(run_id) => { + log::info!("Registered Claude session with run_id: {}", run_id); + let mut run_id_guard = run_id_holder_clone.lock().unwrap(); + *run_id_guard = Some(run_id); + } + Err(e) => { + log::error!("Failed to register Claude session: {}", e); + } + } + } + } + } + } + + // Store live output in registry if we have a run_id + if let Some(run_id) = *run_id_holder_clone.lock().unwrap() { + let _ = registry_clone.append_live_output(run_id, &line); + } + + // Emit the line to the frontend with session isolation if we have session ID + if let Some(ref session_id) = *session_id_holder_clone.lock().unwrap() { + let _ = app_handle.emit(&format!("claude-output:{}", session_id), &line); + } // Also emit to the generic event for backward compatibility let _ = app_handle.emit("claude-output", &line); } }); let app_handle_stderr = app.clone(); - let session_id_clone2 = session_id.clone(); + let session_id_holder_clone2 = session_id_holder.clone(); let stderr_task = tokio::spawn(async move { let mut lines = stderr_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { log::error!("Claude stderr: {}", line); - // Emit error lines to the frontend with session isolation - let _ = app_handle_stderr.emit(&format!("claude-error:{}", session_id_clone2), &line); + // Emit error lines to the frontend with session isolation if we have session ID + if let Some(ref session_id) = *session_id_holder_clone2.lock().unwrap() { + let _ = app_handle_stderr.emit(&format!("claude-error:{}", session_id), &line); + } // Also emit to the generic event for backward compatibility let _ = app_handle_stderr.emit("claude-error", &line); } @@ -1227,7 +1310,9 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St // Wait for the process to complete let app_handle_wait = app.clone(); let claude_state_wait = claude_state.current_process.clone(); - let session_id_clone3 = session_id.clone(); + let session_id_holder_clone3 = session_id_holder.clone(); + let run_id_holder_clone2 = run_id_holder.clone(); + let registry_clone2 = registry.0.clone(); tokio::spawn(async move { let _ = stdout_task.await; let _ = stderr_task.await; @@ -1240,10 +1325,12 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St log::info!("Claude process exited with status: {}", status); // Add a small delay to ensure all messages are processed tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let _ = app_handle_wait.emit( - &format!("claude-complete:{}", session_id_clone3), - status.success(), - ); + if let Some(ref session_id) = *session_id_holder_clone3.lock().unwrap() { + let _ = app_handle_wait.emit( + &format!("claude-complete:{}", session_id), + status.success(), + ); + } // Also emit to the generic event for backward compatibility let _ = app_handle_wait.emit("claude-complete", status.success()); } @@ -1251,24 +1338,25 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St log::error!("Failed to wait for Claude process: {}", e); // Add a small delay to ensure all messages are processed tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let _ = app_handle_wait - .emit(&format!("claude-complete:{}", session_id_clone3), false); + if let Some(ref session_id) = *session_id_holder_clone3.lock().unwrap() { + let _ = app_handle_wait + .emit(&format!("claude-complete:{}", session_id), false); + } // Also emit to the generic event for backward compatibility let _ = app_handle_wait.emit("claude-complete", false); } } } + // Unregister from ProcessRegistry if we have a run_id + if let Some(run_id) = *run_id_holder_clone2.lock().unwrap() { + let _ = registry_clone2.unregister_process(run_id); + } + // Clear the process from state *current_process = None; }); - // Return the session ID to the frontend - let _ = app.emit( - &format!("claude-session-started:{}", session_id), - session_id.clone(), - ); - Ok(()) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index af26492..9f9173f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -21,12 +21,13 @@ use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings, - get_checkpoint_state_stats, get_claude_settings, get_project_sessions, + get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, - list_directory_contents, list_projects, load_session_history, open_new_session, - read_claude_md_file, restore_checkpoint, resume_claude_code, save_claude_md_file, - save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, - track_session_messages, update_checkpoint_settings, ClaudeProcessState, + list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, + open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, + save_claude_md_file, save_claude_settings, save_system_prompt, search_files, + track_checkpoint_message, track_session_messages, update_checkpoint_settings, + ClaudeProcessState, }; use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, @@ -114,6 +115,8 @@ fn main() { continue_claude_code, resume_claude_code, cancel_claude_execution, + list_running_claude_sessions, + get_claude_session_output, list_directory_contents, search_files, create_checkpoint, diff --git a/src-tauri/src/process/registry.rs b/src-tauri/src/process/registry.rs index 3fd58fb..eb1257e 100644 --- a/src-tauri/src/process/registry.rs +++ b/src-tauri/src/process/registry.rs @@ -4,12 +4,23 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::process::Child; +/// Type of process being tracked +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ProcessType { + AgentRun { + agent_id: i64, + agent_name: String, + }, + ClaudeSession { + session_id: String, + }, +} + /// Information about a running agent process #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProcessInfo { pub run_id: i64, - pub agent_id: i64, - pub agent_name: String, + pub process_type: ProcessType, pub pid: u32, pub started_at: DateTime, pub project_path: String, @@ -28,16 +39,26 @@ pub struct ProcessHandle { /// Registry for tracking active agent processes pub struct ProcessRegistry { processes: Arc>>, // run_id -> ProcessHandle + next_id: Arc>, // Auto-incrementing ID for non-agent processes } impl ProcessRegistry { pub fn new() -> Self { Self { processes: Arc::new(Mutex::new(HashMap::new())), + next_id: Arc::new(Mutex::new(1000000)), // Start at high number to avoid conflicts } } - /// Register a new running process + /// Generate a unique ID for non-agent processes + pub fn generate_id(&self) -> Result { + let mut next_id = self.next_id.lock().map_err(|e| e.to_string())?; + let id = *next_id; + *next_id += 1; + Ok(id) + } + + /// Register a new running agent process pub fn register_process( &self, run_id: i64, @@ -49,12 +70,9 @@ impl ProcessRegistry { model: String, child: Child, ) -> Result<(), String> { - let mut processes = self.processes.lock().map_err(|e| e.to_string())?; - let process_info = ProcessInfo { run_id, - agent_id, - agent_name, + process_type: ProcessType::AgentRun { agent_id, agent_name }, pid, started_at: Utc::now(), project_path, @@ -62,6 +80,52 @@ impl ProcessRegistry { model, }; + self.register_process_internal(run_id, process_info, child) + } + + /// Register a new Claude session (without child process - handled separately) + pub fn register_claude_session( + &self, + session_id: String, + pid: u32, + project_path: String, + task: String, + model: String, + ) -> Result { + let run_id = self.generate_id()?; + + let process_info = ProcessInfo { + run_id, + process_type: ProcessType::ClaudeSession { session_id }, + pid, + started_at: Utc::now(), + project_path, + task, + model, + }; + + // Register without child - Claude sessions use ClaudeProcessState for process management + let mut processes = self.processes.lock().map_err(|e| e.to_string())?; + + let process_handle = ProcessHandle { + info: process_info, + child: Arc::new(Mutex::new(None)), // No child handle for Claude sessions + live_output: Arc::new(Mutex::new(String::new())), + }; + + processes.insert(run_id, process_handle); + Ok(run_id) + } + + /// Internal method to register any process + fn register_process_internal( + &self, + run_id: i64, + process_info: ProcessInfo, + child: Child, + ) -> Result<(), String> { + let mut processes = self.processes.lock().map_err(|e| e.to_string())?; + let process_handle = ProcessHandle { info: process_info, child: Arc::new(Mutex::new(Some(child))), @@ -72,6 +136,34 @@ impl ProcessRegistry { Ok(()) } + /// Get all running Claude sessions + pub fn get_running_claude_sessions(&self) -> Result, String> { + let processes = self.processes.lock().map_err(|e| e.to_string())?; + Ok(processes + .values() + .filter_map(|handle| { + match &handle.info.process_type { + ProcessType::ClaudeSession { .. } => Some(handle.info.clone()), + _ => None, + } + }) + .collect()) + } + + /// Get a specific Claude session by session ID + pub fn get_claude_session_by_id(&self, session_id: &str) -> Result, String> { + let processes = self.processes.lock().map_err(|e| e.to_string())?; + Ok(processes + .values() + .find(|handle| { + match &handle.info.process_type { + ProcessType::ClaudeSession { session_id: sid } => sid == session_id, + _ => false, + } + }) + .map(|handle| handle.info.clone())) + } + /// Unregister a process (called when it completes) #[allow(dead_code)] pub fn unregister_process(&self, run_id: i64) -> Result<(), String> { @@ -90,6 +182,20 @@ impl ProcessRegistry { .collect()) } + /// Get all running agent processes + pub fn get_running_agent_processes(&self) -> Result, String> { + let processes = self.processes.lock().map_err(|e| e.to_string())?; + Ok(processes + .values() + .filter_map(|handle| { + match &handle.info.process_type { + ProcessType::AgentRun { .. } => Some(handle.info.clone()), + _ => None, + } + }) + .collect()) + } + /// Get a specific running process #[allow(dead_code)] pub fn get_process(&self, run_id: i64) -> Result, String> { diff --git a/src/App.tsx b/src/App.tsx index eefaba1..97f71f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ProjectList } from "@/components/ProjectList"; import { SessionList } from "@/components/SessionList"; +import { RunningClaudeSessions } from "@/components/RunningClaudeSessions"; import { Topbar } from "@/components/Topbar"; import { MarkdownEditor } from "@/components/MarkdownEditor"; import { ClaudeFileEditor } from "@/components/ClaudeFileEditor"; @@ -36,6 +37,8 @@ function App() { const [showNFO, setShowNFO] = useState(false); const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); + const [activeClaudeSessionId, setActiveClaudeSessionId] = useState(null); + const [isClaudeStreaming, setIsClaudeStreaming] = useState(false); // Load projects on mount when in projects view useEffect(() => { @@ -52,7 +55,7 @@ function App() { const handleSessionSelected = (event: CustomEvent) => { const { session } = event.detail; setSelectedSession(session); - setView("claude-code-session"); + handleViewChange("claude-code-session"); }; const handleClaudeNotFound = () => { @@ -106,7 +109,7 @@ function App() { * Opens a new Claude Code session in the interactive UI */ const handleNewSession = async () => { - setView("claude-code-session"); + handleViewChange("claude-code-session"); setSelectedSession(null); }; @@ -123,7 +126,7 @@ function App() { */ const handleEditClaudeFile = (file: ClaudeMdFile) => { setEditingClaudeFile(file); - setView("claude-file-editor"); + handleViewChange("claude-file-editor"); }; /** @@ -131,7 +134,27 @@ function App() { */ const handleBackFromClaudeFileEditor = () => { setEditingClaudeFile(null); - setView("projects"); + handleViewChange("projects"); + }; + + /** + * Handles view changes with navigation protection + */ + const handleViewChange = (newView: View) => { + // Check if we're navigating away from an active Claude session + if (view === "claude-code-session" && isClaudeStreaming && activeClaudeSessionId) { + const shouldLeave = window.confirm( + "Claude is still responding. If you navigate away, Claude will continue running in the background.\n\n" + + "You can return to this session from the Projects view.\n\n" + + "Do you want to continue?" + ); + + if (!shouldLeave) { + return; + } + } + + setView(newView); }; const renderContent = () => { @@ -163,7 +186,7 @@ function App() { > setView("agents")} + onClick={() => handleViewChange("agents")} >
@@ -180,7 +203,7 @@ function App() { > setView("projects")} + onClick={() => handleViewChange("projects")} >
@@ -197,21 +220,21 @@ function App() { case "agents": return (
- setView("welcome")} /> + handleViewChange("welcome")} />
); case "editor": return (
- setView("welcome")} /> + handleViewChange("welcome")} />
); case "settings": return (
- setView("welcome")} /> + handleViewChange("welcome")} />
); @@ -229,7 +252,7 @@ function App() { + {/* Running Claude Sessions */} + + {/* Project list */} {projects.length > 0 ? ( { setSelectedSession(null); - setView("projects"); + handleViewChange("projects"); + }} + onStreamingChange={(isStreaming, sessionId) => { + setIsClaudeStreaming(isStreaming); + setActiveClaudeSessionId(sessionId); }} /> ); case "usage-dashboard": return ( - setView("welcome")} /> + handleViewChange("welcome")} /> ); case "mcp": return ( - setView("welcome")} /> + handleViewChange("welcome")} /> ); default: @@ -363,10 +393,10 @@ function App() {
{/* Topbar */} setView("editor")} - onSettingsClick={() => setView("settings")} - onUsageClick={() => setView("usage-dashboard")} - onMCPClick={() => setView("mcp")} + onClaudeClick={() => handleViewChange("editor")} + onSettingsClick={() => handleViewChange("settings")} + onUsageClick={() => handleViewChange("usage-dashboard")} + onMCPClick={() => handleViewChange("mcp")} onInfoClick={() => setShowNFO(true)} /> diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index ac0c015..660358c 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -49,6 +49,10 @@ interface ClaudeCodeSessionProps { * Optional className for styling */ className?: string; + /** + * Callback when streaming state changes + */ + onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void; } /** @@ -62,6 +66,7 @@ export const ClaudeCodeSession: React.FC = ({ initialProjectPath = "", onBack, className, + onStreamingChange, }) => { const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || ""); const [messages, setMessages] = useState([]); @@ -196,6 +201,16 @@ export const ClaudeCodeSession: React.FC = ({ } }, [session]); + // Report streaming state changes + useEffect(() => { + onStreamingChange?.(isLoading, claudeSessionId); + }, [isLoading, claudeSessionId, onStreamingChange]); + + // Check for active Claude sessions on mount + useEffect(() => { + checkForActiveSession(); + }, []); + // Auto-scroll to bottom when new messages arrive useEffect(() => { @@ -246,6 +261,89 @@ export const ClaudeCodeSession: React.FC = ({ } }; + const checkForActiveSession = async () => { + // If we have a session prop, check if it's still active + if (session) { + try { + const activeSessions = await api.listRunningClaudeSessions(); + const activeSession = activeSessions.find((s: any) => { + if ('process_type' in s && s.process_type && 'ClaudeSession' in s.process_type) { + return (s.process_type as any).ClaudeSession.session_id === session.id; + } + return false; + }); + + if (activeSession) { + // Session is still active, reconnect to its stream + console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id); + setClaudeSessionId(session.id); + + // Get any buffered output + const bufferedOutput = await api.getClaudeSessionOutput(session.id); + if (bufferedOutput) { + // Parse and add buffered messages + const lines = bufferedOutput.split('\n').filter((line: string) => line.trim()); + for (const line of lines) { + try { + const message = JSON.parse(line) as ClaudeStreamMessage; + setMessages(prev => [...prev, message]); + setRawJsonlOutput(prev => [...prev, line]); + } catch (err) { + console.error('Failed to parse buffered message:', err); + } + } + } + + // Set up listeners for the active session + reconnectToSession(session.id); + } + } catch (err) { + console.error('Failed to check for active sessions:', err); + } + } + }; + + const reconnectToSession = async (sessionId: string) => { + console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId); + + // Clean up previous listeners + unlistenRefs.current.forEach(unlisten => unlisten()); + unlistenRefs.current = []; + + // Set up session-specific listeners + const outputUnlisten = await listen(`claude-output:${sessionId}`, async (event) => { + try { + console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload); + + // Store raw JSONL + setRawJsonlOutput(prev => [...prev, event.payload]); + + // Parse and display + const message = JSON.parse(event.payload) as ClaudeStreamMessage; + setMessages(prev => [...prev, message]); + } catch (err) { + console.error("Failed to parse message:", err, event.payload); + } + }); + + const errorUnlisten = await listen(`claude-error:${sessionId}`, (event) => { + console.error("Claude error:", event.payload); + setError(event.payload); + }); + + const completeUnlisten = await listen(`claude-complete:${sessionId}`, async (event) => { + console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload); + setIsLoading(false); + hasActiveSessionRef.current = false; + }); + + unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; + + // Mark as loading to show the session is active + setIsLoading(true); + hasActiveSessionRef.current = true; + }; + const handleSelectPath = async () => { try { const selected = await open({ diff --git a/src/components/RunningClaudeSessions.tsx b/src/components/RunningClaudeSessions.tsx new file mode 100644 index 0000000..2cf434b --- /dev/null +++ b/src/components/RunningClaudeSessions.tsx @@ -0,0 +1,175 @@ +import React, { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { Play, Loader2, Terminal, AlertCircle } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { api, type ProcessInfo, type Session } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { formatISOTimestamp } from "@/lib/date-utils"; + +interface RunningClaudeSessionsProps { + /** + * Callback when a running session is clicked to resume + */ + onSessionClick?: (session: Session) => void; + /** + * Optional className for styling + */ + className?: string; +} + +/** + * Component to display currently running Claude sessions + */ +export const RunningClaudeSessions: React.FC = ({ + onSessionClick, + className, +}) => { + const [runningSessions, setRunningSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadRunningSessions(); + + // Poll for updates every 5 seconds + const interval = setInterval(loadRunningSessions, 5000); + return () => clearInterval(interval); + }, []); + + const loadRunningSessions = async () => { + try { + const sessions = await api.listRunningClaudeSessions(); + setRunningSessions(sessions); + setError(null); + } catch (err) { + console.error("Failed to load running sessions:", err); + setError("Failed to load running sessions"); + } finally { + setLoading(false); + } + }; + + const handleResumeSession = (processInfo: ProcessInfo) => { + // Extract session ID from process type + if ('ClaudeSession' in processInfo.process_type) { + const sessionId = processInfo.process_type.ClaudeSession.session_id; + + // Create a minimal session object for resumption + const session: Session = { + id: sessionId, + project_id: processInfo.project_path.replace(/[^a-zA-Z0-9]/g, '-'), + project_path: processInfo.project_path, + created_at: new Date(processInfo.started_at).getTime() / 1000, + }; + + // Emit event to navigate to the session + const event = new CustomEvent('claude-session-selected', { + detail: { session, projectPath: processInfo.project_path } + }); + window.dispatchEvent(event); + + onSessionClick?.(session); + } + }; + + if (loading && runningSessions.length === 0) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (runningSessions.length === 0) { + return null; + } + + return ( +
+
+
+
+

Active Claude Sessions

+
+ + ({runningSessions.length} running) + +
+ +
+ {runningSessions.map((session) => { + const sessionId = 'ClaudeSession' in session.process_type + ? session.process_type.ClaudeSession.session_id + : null; + + if (!sessionId) return null; + + return ( + + + handleResumeSession(session)} + > +
+
+ +
+
+

+ {sessionId.substring(0, 20)}... +

+ + Running + +
+ +

+ {session.project_path} +

+ +
+ Started: {formatISOTimestamp(session.started_at)} + Model: {session.model} + {session.task && ( + + Task: {session.task} + + )} +
+
+
+ + +
+
+
+
+ ); + })} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index ac0149d..2c9604b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,4 +27,5 @@ export * from "./ui/tooltip"; export * from "./ui/popover"; export * from "./ui/pagination"; export * from "./ui/split-pane"; -export * from "./ui/scroll-area"; \ No newline at end of file +export * from "./ui/scroll-area"; +export * from "./RunningClaudeSessions"; \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3d5f2dd..f6f3480 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,21 @@ import { invoke } from "@tauri-apps/api/core"; +/** Process type for tracking in ProcessRegistry */ +export type ProcessType = + | { AgentRun: { agent_id: number; agent_name: string } } + | { ClaudeSession: { session_id: string } }; + +/** Information about a running process */ +export interface ProcessInfo { + run_id: number; + process_type: ProcessType; + pid: number; + started_at: string; + project_path: string; + task: string; + model: string; +} + /** * Represents a project in the ~/.claude/projects directory */ @@ -1045,6 +1061,23 @@ export const api = { return invoke("cancel_claude_execution", { sessionId }); }, + /** + * Lists all currently running Claude sessions + * @returns Promise resolving to list of running Claude sessions + */ + async listRunningClaudeSessions(): Promise { + return invoke("list_running_claude_sessions"); + }, + + /** + * Gets live output from a Claude session + * @param sessionId - The session ID to get output for + * @returns Promise resolving to the current live output + */ + async getClaudeSessionOutput(sessionId: string): Promise { + return invoke("get_claude_session_output", { sessionId }); + }, + /** * Lists files and directories in a given path */