diff --git a/.gitignore b/.gitignore index e140c1fdf..dfbaa64c3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ temp_lib/ AGENTS.md CLAUDE.md *_TASK.md +TODO.md # Claude project-specific files .claude/ diff --git a/bun.lock b/bun.lock index 0152c93a4..586a17b19 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opcode", @@ -58,6 +59,10 @@ "typescript": "~5.6.2", "vite": "^6.0.3", }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.6", + "@rollup/rollup-linux-x64-gnu": "^4.45.1", + }, }, }, "trustedDependencies": [ @@ -139,7 +144,7 @@ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], @@ -357,7 +362,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="], @@ -1061,6 +1066,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1087,6 +1094,8 @@ "rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5569e2304..1e07b3528 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3566,6 +3566,7 @@ dependencies = [ "libc", "log", "objc", + "open", "regex", "reqwest", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91288d4f6..77f69060c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,6 +5,7 @@ description = "GUI app and Toolkit for Claude Code" authors = ["mufeedvh", "123vviekr"] license = "AGPL-3.0" edition = "2021" +default-run = "opcode" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -56,6 +57,7 @@ sha2 = "0.10" zstd = "0.13" uuid = { version = "1.6", features = ["v4", "serde"] } walkdir = "2" +open = "5" serde_yaml = "0.9" axum = { version = "0.8", features = ["ws"] } tower = "0.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5e231dacf..89c0f973f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -55,6 +55,10 @@ "core:window:allow-unmaximize", "core:window:allow-close", "core:window:allow-is-maximized", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "core:menu:default", + "core:menu:allow-new", + "core:menu:allow-append", + "core:menu:allow-popup" ] } diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs index 0ce4ce48e..13cf0bb70 100644 --- a/src-tauri/src/claude_binary.rs +++ b/src-tauri/src/claude_binary.rs @@ -689,5 +689,16 @@ pub fn create_command_with_env(program: &str) -> Command { } } + // Ensure ~/.local/bin is in PATH (common install location for claude via npm/npx) + if let Ok(home) = std::env::var("HOME") { + let local_bin = format!("{}/.local/bin", home); + let current_path = std::env::var("PATH").unwrap_or_default(); + if !current_path.contains(&local_bin) { + let new_path = format!("{}:{}", local_bin, current_path); + debug!("Adding ~/.local/bin to PATH: {}", local_bin); + cmd.env("PATH", new_path); + } + } + cmd } diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 529eb1a2a..74caf1124 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -7,18 +7,21 @@ use std::process::Stdio; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; -use tokio::process::{Child, Command}; +use tokio::io::AsyncWriteExt; +use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::Mutex; -/// Global state to track current Claude process +/// Global state to track current Claude process and its stdin for mid-turn injection pub struct ClaudeProcessState { pub current_process: Arc>>, + pub current_stdin: Arc>>, } impl Default for ClaudeProcessState { fn default() -> Self { Self { current_process: Arc::new(Mutex::new(None)), + current_stdin: Arc::new(Mutex::new(None)), } } } @@ -51,6 +54,8 @@ pub struct Session { pub todo_data: Option, /// Unix timestamp when the session file was created pub created_at: u64, + /// Unix timestamp of the last modification to the session file (best proxy for last activity) + pub last_updated_at: u64, /// First user message content (if available) pub first_message: Option, /// Timestamp of the first user message (if available) @@ -299,6 +304,7 @@ fn create_system_command(claude_path: &str, args: Vec, project_path: &st } cmd.current_dir(project_path) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -518,6 +524,13 @@ pub async fn get_project_sessions(project_id: String) -> Result, St .unwrap_or_default() .as_secs(); + let last_updated_at = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + // Extract first user message and timestamp let (first_message, message_timestamp) = extract_first_user_message(&path); @@ -537,6 +550,7 @@ pub async fn get_project_sessions(project_id: String) -> Result, St project_path: project_path.clone(), todo_data, created_at, + last_updated_at, first_message, message_timestamp, }); @@ -544,8 +558,8 @@ pub async fn get_project_sessions(project_id: String) -> Result, St } } - // Sort sessions by creation time (newest first) - sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + // Sort sessions by last activity time (newest first) + sessions.sort_by(|a, b| b.last_updated_at.cmp(&a.last_updated_at)); log::info!( "Found {} sessions for project {}", @@ -933,12 +947,13 @@ pub async fn execute_claude_code( let claude_path = find_claude_binary(&app)?; let args = vec![ - "-p".to_string(), - prompt.clone(), + "--print".to_string(), "--model".to_string(), model.clone(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -965,12 +980,13 @@ pub async fn continue_claude_code( let args = vec![ "-c".to_string(), // Continue flag - "-p".to_string(), - prompt.clone(), + "--print".to_string(), "--model".to_string(), model.clone(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -1000,12 +1016,13 @@ pub async fn resume_claude_code( let args = vec![ "--resume".to_string(), session_id.clone(), - "-p".to_string(), - prompt.clone(), + "--print".to_string(), "--model".to_string(), model.clone(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -1014,6 +1031,48 @@ pub async fn resume_claude_code( spawn_claude_process(app, cmd, prompt, model, project_path).await } +/// Inject a message into the currently running Claude process stdin (mid-turn injection) +/// This allows steering Claude in real-time while it's working, matching Claude TUI behavior. +#[tauri::command] +pub async fn inject_claude_message( + app: AppHandle, + message: String, +) -> Result<(), String> { + log::info!("Injecting mid-turn message: {}", message); + + let claude_state = app.state::(); + let mut stdin_guard = claude_state.current_stdin.lock().await; + + if let Some(ref mut stdin) = *stdin_guard { + // Claude's stream-json input format expects a JSON object per line. + // Use the same content shape as the initial prompt (an array of typed + // content blocks) so both code paths produce identical message structure. + let json_msg = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [{ + "type": "text", + "text": message + }] + } + }); + let line = format!("{}\n", json_msg.to_string()); + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| format!("Failed to write to Claude stdin: {}", e))?; + stdin + .flush() + .await + .map_err(|e| format!("Failed to flush Claude stdin: {}", e))?; + log::info!("Mid-turn message injected successfully"); + Ok(()) + } else { + Err("No active Claude process to inject message into".to_string()) + } +} + /// Cancel the currently running Claude Code execution #[tauri::command] pub async fn cancel_claude_execution( @@ -1186,9 +1245,10 @@ async fn spawn_claude_process( .spawn() .map_err(|e| format!("Failed to spawn Claude: {}", e))?; - // Get stdout and stderr + // Get stdout, stderr, and stdin let stdout = child.stdout.take().ok_or("Failed to get stdout")?; let stderr = child.stderr.take().ok_or("Failed to get stderr")?; + let stdin = child.stdin.take().ok_or("Failed to get stdin")?; // Get the child PID for logging let pid = child.id().unwrap_or(0); @@ -1202,7 +1262,7 @@ async fn spawn_claude_process( 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) + // Store the child process and stdin in global state let claude_state = app.state::(); { let mut current_process = claude_state.current_process.lock().await; @@ -1213,6 +1273,20 @@ async fn spawn_claude_process( } *current_process = Some(child); } + { + let mut current_stdin = claude_state.current_stdin.lock().await; + // Write the initial prompt as the first stream-json message + let mut stdin_writer = stdin; + let initial_msg = format!("{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":[{{\"type\":\"text\",\"text\":{}}}]}}}}\n", serde_json::to_string(&prompt).unwrap_or_default()); + use tokio::io::AsyncWriteExt; + if let Err(e) = stdin_writer.write_all(initial_msg.as_bytes()).await { + log::error!("Failed to write initial prompt to stdin: {}", e); + } + if let Err(e) = stdin_writer.flush().await { + log::error!("Failed to flush initial prompt to stdin: {}", e); + } + *current_stdin = Some(stdin_writer); + } // Spawn tasks to read stdout and stderr let app_handle = app.clone(); @@ -1223,6 +1297,7 @@ async fn spawn_claude_process( let project_path_clone = project_path.clone(); let prompt_clone = prompt.clone(); let model_clone = model.clone(); + let claude_stdin_close = claude_state.current_stdin.clone(); let stdout_task = tokio::spawn(async move { let mut lines = stdout_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { @@ -1270,6 +1345,16 @@ async fn spawn_claude_process( } // Also emit to the generic event for backward compatibility let _ = app_handle.emit("claude-output", &line); + + // When Claude emits a "result" message, the turn is complete. + // Close stdin so Claude knows no more input is coming and exits cleanly. + if let Ok(msg) = serde_json::from_str::(&line) { + if msg["type"] == "result" { + log::info!("Claude turn complete (result message), closing stdin"); + let mut stdin_guard = claude_stdin_close.lock().await; + *stdin_guard = None; // dropping ChildStdin closes the pipe + } + } } }); @@ -1291,6 +1376,7 @@ async fn spawn_claude_process( // Wait for the process to complete let app_handle_wait = app.clone(); let claude_state_wait = claude_state.current_process.clone(); + let claude_stdin_wait = claude_state.current_stdin.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(); @@ -1332,8 +1418,10 @@ async fn spawn_claude_process( let _ = registry_clone2.unregister_process(run_id); } - // Clear the process from state + // Clear the process and stdin from state *current_process = None; + let mut stdin_guard = claude_stdin_wait.lock().await; + *stdin_guard = None; }); Ok(()) @@ -2188,6 +2276,126 @@ pub async fn validate_hook_command(command: String) -> Result Result { + let base = dirs::home_dir() + .context("Could not find home directory")?; + Ok(base.join(".claude").join("opcode-metadata.json")) +} + +/// Reads the opcode metadata map from `~/.claude/opcode-metadata.json` +fn read_opcode_metadata() -> serde_json::Map { + let path = match get_opcode_metadata_path() { + Ok(p) => p, + Err(_) => return serde_json::Map::new(), + }; + if !path.exists() { + return serde_json::Map::new(); + } + fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +/// Writes the opcode metadata map back to disk +fn write_opcode_metadata(map: &serde_json::Map) -> Result<(), String> { + let path = get_opcode_metadata_path().map_err(|e| e.to_string())?; + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + } + let json = serde_json::to_string_pretty(&serde_json::Value::Object(map.clone())) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Failed to write opcode-metadata.json: {}", e)) +} + +/// Deletes a session: removes the `.jsonl` file and any same-name subdirectory +/// under `~/.claude/projects//`. +/// +/// Because we don't store per-session `project_id` here we scan all project +/// directories for the matching JSONL file. +#[tauri::command] +pub async fn delete_session(session_id: String) -> Result<(), String> { + log::info!("Deleting session: {}", session_id); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Err("Projects directory does not exist".to_string()); + } + + let mut deleted = false; + + let entries = fs::read_dir(&projects_dir) + .map_err(|e| format!("Failed to read projects directory: {}", e))?; + + for entry in entries.flatten() { + let project_dir = entry.path(); + if !project_dir.is_dir() { + continue; + } + + let jsonl_path = project_dir.join(format!("{}.jsonl", session_id)); + if jsonl_path.exists() { + fs::remove_file(&jsonl_path) + .map_err(|e| format!("Failed to delete session file: {}", e))?; + deleted = true; + log::info!("Deleted session file: {:?}", jsonl_path); + } + + // Also remove a matching subdirectory if it exists (some Claude versions create one) + let session_subdir = project_dir.join(&session_id); + if session_subdir.exists() && session_subdir.is_dir() { + fs::remove_dir_all(&session_subdir) + .map_err(|e| format!("Failed to delete session directory: {}", e))?; + log::info!("Deleted session directory: {:?}", session_subdir); + } + } + + if !deleted { + return Err(format!("Session not found: {}", session_id)); + } + + // Also remove from metadata + let mut meta = read_opcode_metadata(); + if meta.remove(&session_id).is_some() { + let _ = write_opcode_metadata(&meta); + } + + Ok(()) +} + +/// Saves a custom display name for a session in `~/.claude/opcode-metadata.json` +#[tauri::command] +pub async fn rename_session(session_id: String, name: String) -> Result<(), String> { + log::info!("Renaming session {} to {}", session_id, name); + + let mut meta = read_opcode_metadata(); + meta.insert(session_id, serde_json::Value::String(name)); + write_opcode_metadata(&meta) +} + +/// Returns the custom display name for a session, or null if none is set +#[tauri::command] +pub async fn get_session_name(session_id: String) -> Result, String> { + let meta = read_opcode_metadata(); + Ok(meta + .get(&session_id) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())) +} + +#[tauri::command] +pub async fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { + #[cfg(debug_assertions)] + window.open_devtools(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adbcf..2cdcaa735 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,12 +20,13 @@ use commands::agents::{ use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, clear_checkpoint_manager, continue_claude_code, create_checkpoint, create_project, - execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, - get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, - get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, - get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, - list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, - open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, + delete_session, execute_claude_code, find_claude_md_files, fork_from_checkpoint, open_devtools, + get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, + get_claude_session_output, get_claude_settings, get_home_directory, get_hooks_config, + get_project_sessions, get_recently_modified_files, get_session_name, get_session_timeline, + get_system_prompt, list_checkpoints, list_directory_contents, list_projects, + list_running_claude_sessions, load_session_history, inject_claude_message, open_new_session, + read_claude_md_file, rename_session, 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, update_hooks_config, validate_hook_command, ClaudeProcessState, @@ -55,7 +56,28 @@ fn main() { // Initialize logger env_logger::init(); + // Intercept external navigation and open in system browser instead + let nav_plugin = tauri::plugin::Builder::::new("nav-guard") + .on_navigation(|_webview, url| { + let url_str = url.as_str(); + if url_str.starts_with("tauri://") + || url_str.starts_with("http://localhost") + || url_str.starts_with("https://localhost") + || url_str.starts_with("http://127.0.0.1") + || url_str.starts_with("asset://") + { + return true; + } + if url_str.starts_with("http://") || url_str.starts_with("https://") { + let _ = open::that(url_str); + return false; + } + true + }) + .build(); + tauri::Builder::default() + .plugin(nav_plugin) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .setup(|app| { @@ -203,6 +225,7 @@ fn main() { continue_claude_code, resume_claude_code, cancel_claude_execution, + inject_claude_message, list_running_claude_sessions, get_claude_session_output, list_directory_contents, @@ -211,6 +234,10 @@ fn main() { get_hooks_config, update_hooks_config, validate_hook_command, + // Session metadata + delete_session, + rename_session, + get_session_name, // Checkpoint Management create_checkpoint, restore_checkpoint, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f82be8a04..550ed3f4d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,11 +4,13 @@ "version": "0.2.1", "identifier": "opcode.asterisk.so", "build": { - "beforeDevCommand": "", + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", "beforeBuildCommand": "bun run build", "frontendDist": "../dist" }, "app": { + "withGlobalTauri": true, "macOSPrivateApi": true, "windows": [ { diff --git a/src/App.tsx b/src/App.tsx index 1eb89e8b1..2f5eca8ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { TabContent } from "@/components/TabContent"; import { useTabState } from "@/hooks/useTabState"; import { useAppLifecycle, useTrackEvent } from "@/hooks"; import { StartupIntro } from "@/components/StartupIntro"; +import { Sidebar, SidebarToggleButton } from "@/components/sidebar/Sidebar"; type View = | "welcome" @@ -62,6 +63,7 @@ function AppContent() { const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [projectForSettings, setProjectForSettings] = useState(null); const [previousView] = useState("welcome"); + const [showSidebar, setShowSidebar] = useState(true); // Initialize analytics lifecycle tracking useAppLifecycle(); @@ -340,8 +342,42 @@ function AppContent() { return (
-
- +
+ {/* Session Sidebar */} + setShowSidebar(false)} + onSessionSelect={(session: Session, projectPath: string, displayName: string) => { + window.dispatchEvent( + new CustomEvent('claude-session-selected', { + detail: { session, projectPath, displayName }, + }) + ); + }} + onSessionOpenInNewTab={(session: Session, projectPath: string, displayName: string) => { + window.dispatchEvent( + new CustomEvent('claude-session-selected', { + detail: { session, projectPath, displayName, openInNewTab: true }, + }) + ); + }} + onNewSession={(projectPath: string) => { + window.dispatchEvent( + new CustomEvent('new-session-for-project', { + detail: { projectPath }, + }) + ); + }} + /> + {/* Collapse toggle strip (visible when sidebar is closed) */} + setShowSidebar(true)} + /> + {/* Main tab content area */} +
+ +
); diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..6c2cf73fc 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -14,22 +14,17 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; +import { apiCall } from "@/lib/apiAdapter"; import { cn } from "@/lib/utils"; // Conditional imports for Tauri APIs -let tauriListen: any; +import { listen as tauriListenImport } from "@tauri-apps/api/event"; type UnlistenFn = () => void; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require("@tauri-apps/api/event").listen; - } -} catch (e) { - console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); -} +console.log('[ClaudeCodeSession] BUILD v7 - Tauri listen imported:', typeof tauriListenImport); // Web-compatible replacements -const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { +const listen = tauriListenImport || ((eventName: string, callback: (event: any) => void) => { console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); // In web mode, listen for DOM events @@ -199,7 +194,17 @@ export const ClaudeCodeSession: React.FC = ({ // Filter out messages that shouldn't be displayed const displayableMessages = useMemo(() => { + // Only these message types produce visible output in StreamMessage. + // Everything else (e.g. opcode-internal "last-prompt", "ai-title", "mode", + // "queue-operation", "attachment" records) renders as null and would + // otherwise leave empty placeholder rows that pile up as large gaps in the + // virtualized list. + const renderableTypes = ["assistant", "user", "result", "system", "summary"]; return messages.filter((message, index) => { + if (!renderableTypes.includes(String(message.type))) { + return false; + } + // Skip meta messages that don't have meaningful content if (message.isMeta && !message.leafUuid && !message.summary) { return false; @@ -495,14 +500,25 @@ export const ClaudeCodeSession: React.FC = ({ return; } - // If already loading, queue the prompt + // If already loading, inject mid-turn (matching Claude TUI behavior) if (isLoading) { - const newPrompt = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - prompt, - model - }; - setQueuedPrompts(prev => [...prev, newPrompt]); + console.log('[ClaudeCodeSession] BUILD v7 - mid-turn inject:', prompt); + try { + await apiCall('inject_claude_message', { message: prompt }); + // Add the user message to the UI immediately + setMessages(prev => [...prev, { + type: 'user', + content: prompt, + timestamp: new Date().toISOString(), + } as any]); + } catch (e) { + console.error('[ClaudeCodeSession] inject failed, queuing instead:', e); + setQueuedPrompts(prev => [...prev, { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + prompt, + model + }]); + } return; } @@ -1233,22 +1249,22 @@ export const ClaudeCodeSession: React.FC = ({ minHeight: '100px', }} > - - {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const message = displayableMessages[virtualItem.index]; - return ( + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = displayableMessages[virtualItem.index]; + return ( +
el && rowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.3 }} - className="absolute inset-x-4 pb-4" - style={{ - top: virtualItem.start, - }} > = ({ onLinkDetected={handleLinkDetected} /> - ); - })} - +
+ ); + })}
{/* Loading indicator under the latest message */} diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..3d3baa897 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -1031,25 +1031,33 @@ const FloatingPromptInputInner = ( - - - - - + + + + + + + + + @@ -1263,26 +1271,36 @@ const FloatingPromptInputInner = ( - - + + + + + + + + diff --git a/src/components/RunningClaudeSessions.tsx b/src/components/RunningClaudeSessions.tsx index 496dec862..bd029caa2 100644 --- a/src/components/RunningClaudeSessions.tsx +++ b/src/components/RunningClaudeSessions.tsx @@ -61,6 +61,7 @@ export const RunningClaudeSessions: React.FC = ({ 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, + last_updated_at: new Date(processInfo.started_at).getTime() / 1000, }; // Emit event to navigate to the session diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index ae43a5934..2870448e6 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -4,7 +4,9 @@ import { User, Bot, AlertCircle, - CheckCircle2 + CheckCircle2, + Copy, + Check } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; @@ -41,6 +43,70 @@ import { WebFetchWidget } from "./ToolWidgets"; +/** + * A fenced code block rendered with syntax highlighting and a copy-to-clipboard + * button shown on hover. Only used for block-level code, not inline code. + */ +const CodeBlock: React.FC<{ + language: string; + value: string; + syntaxTheme: any; +}> = ({ language, value, syntaxTheme }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy code block:", err); + } + }; + + return ( +
+ + + {value} + +
+ ); +}; + +function markdownComponents(syntaxTheme: any) { + return { + code({ node, inline, className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ''); + // Block-level code (fenced, with a language) gets syntax highlighting and + // a copy button. Inline code is left untouched. + return !inline && match ? ( + + ) : ( + + {children} + + ); + }, + }; +} + interface StreamMessageProps { message: ClaudeStreamMessage; className?: string; @@ -131,25 +197,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
- {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} + components={markdownComponents(syntaxTheme)} > {textContent} @@ -360,7 +408,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Otherwise render as plain text return ( -
+
{contentStr}
); @@ -612,13 +660,13 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Text content if (content.type === "text") { // Handle both string and object formats - const textContent = typeof content.text === 'string' - ? content.text + const textContent = typeof content.text === 'string' + ? content.text : (content.text?.text || JSON.stringify(content.text)); - + renderedSomething = true; return ( -
+
{textContent}
); @@ -660,25 +708,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
- {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} + components={markdownComponents(syntaxTheme)} > {message.result} diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index e306324ed..7cb51aeab 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -449,40 +449,50 @@ export const TabContent: React.FC = () => { closeTab(tabId); }; + const makeTabTitle = (session: { project_path: string; first_message?: string; id: string }, displayName?: string) => { + const projectName = session.project_path.split('/').pop() || 'Session'; + const sessionName = displayName + || (session.first_message ? session.first_message.trim().slice(0, 20) : session.id.slice(0, 8)); + return `${projectName} - ${sessionName}`; + }; + const handleClaudeSessionSelected = (event: CustomEvent) => { - const { session } = event.detail; - // Check if there's an existing tab for this session - const existingTab = findTabBySessionId(session.id); - if (existingTab) { - // If tab exists, just switch to it - updateTab(existingTab.id, { - sessionData: session, - title: session.project_path.split('/').pop() || 'Session', - }); - window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } })); + const { session, openInNewTab, displayName } = event.detail; + const title = makeTabTitle(session, displayName); + + if (openInNewTab) { + const existingTab = findTabBySessionId(session.id); + if (existingTab) { + updateTab(existingTab.id, { sessionData: session, title }); + window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } })); + } else { + const newTabId = createChatTab(session.id, title, session.project_path); + updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path }); + } } else { - // If we're in a projects tab, update it to show the session - // Otherwise create a new tab (for compatibility with other parts of the app) const currentTab = tabs.find(t => t.id === activeTabId); - if (currentTab && currentTab.type === 'projects') { + if (currentTab) { updateTab(currentTab.id, { type: 'chat', - title: session.project_path.split('/').pop() || 'Session', + title, sessionId: session.id, - sessionData: session, - initialProjectPath: session.project_path - }); - } else { - const projectName = session.project_path.split('/').pop() || 'Session'; - const newTabId = createChatTab(session.id, projectName, session.project_path); - updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path, }); + } else { + const newTabId = createChatTab(session.id, title, session.project_path); + updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path }); } } }; + const handleNewSessionForProject = (event: CustomEvent) => { + const { projectPath } = event.detail; + const projectName = projectPath.split('/').pop() || 'New Session'; + const newTabId = createChatTab(undefined, projectName, projectPath); + updateTab(newTabId, { initialProjectPath: projectPath }); + }; + window.addEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener); window.addEventListener('open-claude-file', handleOpenClaudeFile as EventListener); window.addEventListener('open-agent-execution', handleOpenAgentExecution as EventListener); @@ -490,6 +500,7 @@ export const TabContent: React.FC = () => { window.addEventListener('open-import-agent-tab', handleOpenImportAgentTab); window.addEventListener('close-tab', handleCloseTab as EventListener); window.addEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener); + window.addEventListener('new-session-for-project', handleNewSessionForProject as EventListener); return () => { window.removeEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener); window.removeEventListener('open-claude-file', handleOpenClaudeFile as EventListener); @@ -498,6 +509,7 @@ export const TabContent: React.FC = () => { window.removeEventListener('open-import-agent-tab', handleOpenImportAgentTab); window.removeEventListener('close-tab', handleCloseTab as EventListener); window.removeEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener); + window.removeEventListener('new-session-for-project', handleNewSessionForProject as EventListener); }; }, [createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab]); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx new file mode 100644 index 000000000..8c7516cc1 --- /dev/null +++ b/src/components/sidebar/Sidebar.tsx @@ -0,0 +1,248 @@ +/** + * Sidebar component — collapsible left panel showing projects & sessions. + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { apiCall } from '@/lib/apiAdapter'; +import { SidebarProjectItem } from './SidebarProjectItem'; +import type { Project, Session } from '@/lib/api'; + +interface SidebarProps { + onSessionSelect: (session: Session, projectPath: string, displayName: string) => void; + onSessionOpenInNewTab: (session: Session, projectPath: string, displayName: string) => void; + onNewSession: (projectPath: string) => void; + activeSessionId?: string; + isOpen: boolean; + onToggle: () => void; + className?: string; +} + +const SIDEBAR_WIDTH_DEFAULT = 260; +const SIDEBAR_WIDTH_MIN = 180; +const SIDEBAR_WIDTH_MAX = 480; +const RUNNING_SESSIONS_POLL_INTERVAL = 5000; + +export const Sidebar: React.FC = ({ + onSessionSelect, + onSessionOpenInNewTab, + onNewSession, + activeSessionId, + isOpen, + onToggle, + className, +}) => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); + const [reloadSignal, setReloadSignal] = useState(0); + const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_WIDTH_DEFAULT); + const pollingIntervalRef = useRef | null>(null); + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + const dragStartWidthRef = useRef(0); + + const loadProjects = async () => { + setLoading(true); + setError(null); + try { + const result = await apiCall('list_projects'); + setProjects(result); + setReloadSignal((n) => n + 1); + } catch (err) { + console.error('[Sidebar] Failed to load projects:', err); + setError('Failed to load projects'); + } finally { + setLoading(false); + } + }; + + const pollRunningSessions = async () => { + try { + const claudeRuns = await apiCall>('list_running_claude_sessions'); + const ids = new Set( + claudeRuns + .map((r) => r.process_type?.ClaudeSession?.session_id) + .filter((id): id is string => Boolean(id)) + ); + setRunningSessionIds(ids); + } catch { + // Polling failures are non-fatal; ignore and retry on the next interval + } + }; + + useEffect(() => { + if (isOpen) { + loadProjects(); + pollRunningSessions(); + pollingIntervalRef.current = setInterval(pollRunningSessions, RUNNING_SESSIONS_POLL_INTERVAL); + } else { + if (pollingIntervalRef.current !== null) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + } + return () => { + if (pollingIntervalRef.current !== null) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [isOpen]); + + // Sidebar-wide context menu: always show our custom menu anywhere in the sidebar + const handleSidebarContextMenu = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only handle events that weren't already handled by a session item + // (session items call stopPropagation so this only fires for non-session areas) + try { + const { Menu, MenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const refreshItem = await MenuItem.new({ + id: 'refresh-sessions', + text: 'Refresh Sessions', + action: () => loadProjects(), + }); + + const menuItems: any[] = [refreshItem]; + + if (import.meta.env.DEV) { + const { PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const sep = await PredefinedMenuItem.new({ item: 'Separator' }); + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(sep, inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[Sidebar] Failed to show context menu:', err); + } + }, []); + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + dragStartXRef.current = e.clientX; + dragStartWidthRef.current = sidebarWidth; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isDraggingRef.current) return; + const delta = ev.clientX - dragStartXRef.current; + const next = Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, dragStartWidthRef.current + delta)); + setSidebarWidth(next); + }; + + const onMouseUp = () => { + isDraggingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + + return ( +
+ {isOpen && ( + <> + {/* Sidebar header */} +
+ + Sessions + +
+ + +
+
+ + {/* Scrollable project list */} + +
+ {error && ( +

{error}

+ )} + {!error && projects.length === 0 && !loading && ( +

No projects found

+ )} + {projects.map((project) => ( + + ))} +
+
+ + )} + {/* Drag handle — sits on the right edge */} + {isOpen && ( +
+ )} +
+ ); +}; + +/** + * Small toggle button shown outside the sidebar when it is collapsed. + */ +export const SidebarToggleButton: React.FC<{ + onClick: () => void; + isOpen: boolean; +}> = ({ onClick, isOpen }) => { + if (isOpen) return null; + return ( + + ); +}; diff --git a/src/components/sidebar/SidebarProjectItem.tsx b/src/components/sidebar/SidebarProjectItem.tsx new file mode 100644 index 000000000..ed96d1bac --- /dev/null +++ b/src/components/sidebar/SidebarProjectItem.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from 'react'; +import { ChevronRight, ChevronDown, FolderOpen, Loader2, Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiCall } from '@/lib/apiAdapter'; +import { SidebarSessionItem } from './SidebarSessionItem'; +import type { Session } from '@/lib/api'; + +interface ProjectItem { + id: string; + path: string; +} + +interface SidebarProjectItemProps { + project: ProjectItem; + activeSessionId?: string; + runningSessionIds?: Set; + onSessionSelect: (session: Session, projectPath: string, displayName: string) => void; + onSessionSelectNewTab: (session: Session, projectPath: string, displayName: string) => void; + onNewSession: (projectPath: string) => void; + /** Incrementing this triggers a force-reload of sessions without unmounting */ + reloadSignal?: number; +} + +function getProjectBaseName(path: string): string { + // Handle both Unix and Windows-style paths + const parts = path.replace(/\\/g, '/').split('/').filter(Boolean); + return parts[parts.length - 1] || path; +} + +export const SidebarProjectItem: React.FC = ({ + project, + activeSessionId, + runningSessionIds, + onSessionSelect, + onSessionSelectNewTab, + onNewSession, + reloadSignal, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [sessionNames, setSessionNames] = useState>({}); + + const projectName = getProjectBaseName(project.path); + + // When the parent bumps reloadSignal, reload sessions if already expanded + useEffect(() => { + if (reloadSignal && reloadSignal > 0 && isExpanded) { + loadSessions(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reloadSignal]); + + const loadSessions = async (force = false) => { + if (loaded && !force) return; + setLoading(true); + try { + const result = await apiCall('get_project_sessions', { projectId: project.id }); + setSessions(result); + setLoaded(true); + + // Load custom names for all sessions + const namesMap: Record = {}; + await Promise.all( + result.map(async (s) => { + try { + const name = await apiCall('get_session_name', { sessionId: s.id }); + if (name) namesMap[s.id] = name; + } catch (_) { + // no custom name + } + }) + ); + setSessionNames(namesMap); + } catch (err) { + console.error('[Sidebar] Failed to load sessions for project:', project.id, err); + } finally { + setLoading(false); + } + }; + + const handleToggle = () => { + const next = !isExpanded; + setIsExpanded(next); + if (next) loadSessions(); + }; + + const getDisplayName = (session: Session): string => { + if (sessionNames[session.id]) return sessionNames[session.id]; + if (session.first_message) { + const text = session.first_message.trim(); + return text.length > 40 ? text.slice(0, 40) + '...' : text; + } + return session.id.slice(0, 16) + '...'; + }; + + const handleDeleteSession = (sessionId: string) => { + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + }; + + const handleRenameSession = (sessionId: string, newName: string) => { + setSessionNames((prev) => ({ ...prev, [sessionId]: newName })); + }; + + const handleProjectContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + const { Menu, MenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const newSessionItem = await MenuItem.new({ + id: 'new-session', + text: 'New Session', + action: () => onNewSession(project.path), + }); + + const menuItems: any[] = [newSessionItem]; + + if (import.meta.env.DEV) { + const { PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const sep = await PredefinedMenuItem.new({ item: 'Separator' }); + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(sep, inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[SidebarProjectItem] Failed to show context menu:', err); + } + }; + + return ( +
+ {/* Project header row */} + + + {/* Sessions list */} + {isExpanded && ( +
+ {/* New Session button */} + + {sessions.length === 0 && !loading && ( +

No sessions

+ )} + {sessions.map((session) => ( + onSessionSelect(session, project.path, getDisplayName(session))} + onOpenInNewTab={() => onSessionSelectNewTab(session, project.path, getDisplayName(session))} + onRefreshSessions={() => loadSessions(true)} + onDelete={handleDeleteSession} + onRename={handleRenameSession} + /> + ))} +
+ )} +
+ ); +}; diff --git a/src/components/sidebar/SidebarSessionItem.tsx b/src/components/sidebar/SidebarSessionItem.tsx new file mode 100644 index 000000000..8f006fc3f --- /dev/null +++ b/src/components/sidebar/SidebarSessionItem.tsx @@ -0,0 +1,275 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Pencil, Trash2, Check, X, AlertTriangle } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import { formatTimeAgoBrief } from '@/lib/date-utils'; +import { apiCall } from '@/lib/apiAdapter'; +import type { Session } from '@/lib/api'; + +interface SidebarSessionItemProps { + session: Session; + isActive: boolean; + isRunning?: boolean; + displayName: string; + onClick: () => void; + onOpenInNewTab: () => void; + onRefreshSessions: () => void; + onDelete: (sessionId: string) => void; + onRename: (sessionId: string, newName: string) => void; +} + +function getSessionTimestampMs(session: Session): number { + if (session.last_updated_at) return session.last_updated_at * 1000; + return session.created_at * 1000; +} + +const RELATIVE_TIME_UPDATE_INTERVAL = 60_000; + +export const SidebarSessionItem: React.FC = ({ + session, + isActive, + isRunning = false, + displayName, + onClick, + onOpenInNewTab, + onRefreshSessions, + onDelete, + onRename, +}) => { + const [isHovered, setIsHovered] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(displayName); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const inputRef = useRef(null); + + const [relativeTime, setRelativeTime] = useState(() => + formatTimeAgoBrief(getSessionTimestampMs(session)) + ); + + useEffect(() => { + setRelativeTime(formatTimeAgoBrief(getSessionTimestampMs(session))); + const interval = setInterval(() => { + setRelativeTime(formatTimeAgoBrief(getSessionTimestampMs(session))); + }, RELATIVE_TIME_UPDATE_INTERVAL); + return () => clearInterval(interval); + }, [session.id, session.last_updated_at, session.created_at]); + + useEffect(() => { + if (isRenaming && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isRenaming]); + + const handleContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + try { + const { Menu, MenuItem, PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const openInNewTabItem = await MenuItem.new({ + id: 'open-in-new-tab', + text: 'Open in New Tab', + action: () => onOpenInNewTab(), + }); + + const sep1 = await PredefinedMenuItem.new({ item: 'Separator' }); + + const renameItem = await MenuItem.new({ + id: 'rename-session', + text: 'Rename Session', + action: () => { setRenameValue(displayName); setIsRenaming(true); }, + }); + + const deleteItem = await MenuItem.new({ + id: 'delete-session', + text: 'Delete Session', + action: () => setConfirmDeleteOpen(true), + }); + + const sep2 = await PredefinedMenuItem.new({ item: 'Separator' }); + + const reloadItem = await MenuItem.new({ + id: 'refresh-sessions', + text: 'Refresh Sessions', + action: () => onRefreshSessions(), + }); + + const menuItems: any[] = [openInNewTabItem, sep1, renameItem, deleteItem, sep2, reloadItem]; + + // Add "Inspect Element" only in dev mode + if (import.meta.env.DEV) { + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[SidebarSessionItem] Failed to show context menu:', err); + } + }; + + const handleRenameStart = (e: React.MouseEvent) => { + e.stopPropagation(); + setRenameValue(displayName); + setIsRenaming(true); + }; + + const handleRenameCommit = async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== displayName) { + try { + await apiCall('rename_session', { sessionId: session.id, name: trimmed }); + onRename(session.id, trimmed); + } catch (err) { + console.error('[Sidebar] Failed to rename session:', err); + } + } + setIsRenaming(false); + }; + + const handleRenameCancel = () => { + setRenameValue(displayName); + setIsRenaming(false); + }; + + const handleRenameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); handleRenameCommit(); } + else if (e.key === 'Escape') { handleRenameCancel(); } + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setConfirmDeleteOpen(true); + }; + + const handleDeleteConfirm = async () => { + setConfirmDeleteOpen(false); + try { + await apiCall('delete_session', { sessionId: session.id }); + onDelete(session.id); + } catch (err) { + console.error('[Sidebar] Failed to delete session:', err); + } + }; + + return ( + <> + + e.stopPropagation()}> + +
+
+ +
+ Delete Session +
+

+ Are you sure you want to permanently delete{' '} + {displayName}? + This cannot be undone. +

+
+
+ + +
+
+
+ +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={isRenaming ? undefined : onClick} + onContextMenu={handleContextMenu} + > + {isRenaming ? ( +
e.stopPropagation()}> + setRenameValue(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameCommit} + className="h-5 px-1 py-0 text-xs flex-1 min-w-0" + /> + + +
+ ) : ( + <> + {isRunning && ( + + )} +
+ {displayName} + {relativeTime} +
+ + {isHovered && !isActive && ( +
+ + +
+ )} + + )} +
+ + ); +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..309c8703c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -47,6 +47,8 @@ export interface Session { todo_data?: any; /** Unix timestamp when the session file was created */ created_at: number; + /** Unix timestamp of the last modification to the session file (best proxy for last activity) */ + last_updated_at: number; /** First user message content (if available) */ first_message?: string; /** Timestamp of the first user message (if available) */ diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index ece52c491..1c12f54e1 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -251,6 +251,11 @@ function mapCommandToEndpoint(command: string, _params?: any): string { 'get_hooks_config': '/api/hooks/config', 'update_hooks_config': '/api/hooks/config', 'validate_hook_command': '/api/hooks/validate', + + // Session metadata (opcode-specific) + 'delete_session': '/api/sessions/{sessionId}/delete', + 'rename_session': '/api/sessions/{sessionId}/rename', + 'get_session_name': '/api/sessions/{sessionId}/name', // Slash commands 'slash_commands_list': '/api/slash-commands', diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index 70e6b8633..e7a96cd56 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -114,6 +114,24 @@ function getDayName(date: Date): string { * formatTimeAgo(Date.now() - 3600000) // "1 hour ago" * formatTimeAgo(Date.now() - 86400000) // "1 day ago" */ +/** + * Compact relative time: <1m, 3m, 13hr, 2d, 3w, 2mo, 1y + */ +export function formatTimeAgoBrief(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return '<1m'; + if (hours < 1) return `${minutes}m`; + if (days < 1) return `${hours}hr`; + if (days < 7) return `${days}d`; + if (days < 30) return `${Math.floor(days / 7)}w`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +} + export function formatTimeAgo(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; diff --git a/src/lib/linkDetector.tsx b/src/lib/linkDetector.tsx index 5938ba017..ed15a15b5 100644 --- a/src/lib/linkDetector.tsx +++ b/src/lib/linkDetector.tsx @@ -121,9 +121,15 @@ export function makeLinksClickable( { + onClick={async (e) => { e.preventDefault(); - onLinkClick(link.fullUrl); + try { + const { open } = await import('@tauri-apps/plugin-shell'); + await open(link.fullUrl); + } catch { + // Fallback for web/non-Tauri env + onLinkClick(link.fullUrl); + } }} className="text-primary underline hover:text-primary/80 cursor-pointer" title={link.fullUrl} diff --git a/src/services/sessionPersistence.ts b/src/services/sessionPersistence.ts index 2c3f513e9..de91352f3 100644 --- a/src/services/sessionPersistence.ts +++ b/src/services/sessionPersistence.ts @@ -167,7 +167,8 @@ export class SessionPersistenceService { id: data.sessionId, project_id: data.projectId, project_path: data.projectPath, - created_at: data.timestamp / 1000, // Convert to seconds + created_at: data.timestamp / 1000, + last_updated_at: data.timestamp / 1000, first_message: "Restored session" }; }