diff --git a/package-lock.json b/package-lock.json index 9277072..b8ed0a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-store": "^2.4.2", + "ansi-to-html": "^0.7.2", "dompurify": "^3.3.1", "lucide-svelte": "^0.562.0", "marked": "^17.0.1", @@ -1220,6 +1221,21 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/aria-query": { "version": "5.3.2", "license": "Apache-2.0", @@ -1342,6 +1358,15 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.27.2", "dev": true, diff --git a/package.json b/package.json index 6eb465e..bed6688 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-store": "^2.4.2", + "ansi-to-html": "^0.7.2", "dompurify": "^3.3.1", "lucide-svelte": "^0.562.0", "marked": "^17.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7cd5255..8668e17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4757,12 +4757,14 @@ name = "staged" version = "0.1.0" dependencies = [ "agent-client-protocol", + "anyhow", "async-trait", "chrono", "dirs 5.0.1", "futures", "git2", "ignore", + "libc", "log", "notify", "notify-debouncer-full", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 96bbba3..dea0a24 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,8 @@ tauri-plugin-log = "2" # Git integration git2 = "0.19" thiserror = "2.0" +anyhow = "1.0" +libc = "0.2" # File watching notify = "8.0" diff --git a/src-tauri/src/actions/detector.rs b/src-tauri/src/actions/detector.rs new file mode 100644 index 0000000..de9a4b8 --- /dev/null +++ b/src-tauri/src/actions/detector.rs @@ -0,0 +1,260 @@ +//! AI-powered action detection +//! +//! This module uses an AI model to analyze project structure and suggest +//! relevant actions (linting, testing, formatting, etc.) based on common +//! patterns in build files (justfile, Makefile, package.json, etc.). + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use crate::ai::{find_acp_agent, run_acp_prompt_raw}; +use crate::store::ActionType; + +/// A suggested action that was detected +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SuggestedAction { + pub name: String, + pub command: String, + pub action_type: ActionType, + pub auto_commit: bool, + pub source: String, // e.g., "justfile", "Makefile", "package.json" +} + +/// System prompt for AI action detection +const DETECTION_PROMPT_TEMPLATE: &str = r#"You are analyzing a project directory to detect available actions (build, test, lint, format commands). + +Analyze the project structure and suggest actions based on the files present. + +IMPORTANT: Return your response as valid JSON ONLY. Do not include any explanatory text before or after the JSON. + +The response must be a JSON array of action objects. Each action object must have these fields: +- name: string (concise action name, e.g., "Test", "Lint", "Format") +- command: string (exact shell command to run, e.g., "npm test", "just build") +- actionType: string (one of: "prerun", "run", "build", "format", "check", "test", "cleanUp") +- autoCommit: boolean (true if action modifies files and should auto-commit) +- source: string (which file this was detected from, e.g., "package.json", "justfile") + +Action type guidelines: +- "prerun": Commands that should run automatically on worktree creation (like "npm install", "yarn", "pnpm install") +- "build": Commands that compile or build the project (like "npm run build", "cargo build", "just build", "make build") +- "format": Commands that auto-fix code (like "just fmt", "just lint-fix", "prettier --write", "cargo fmt", "ruff format") +- "check": Commands that validate without modifying (like "eslint", "cargo clippy", "mypy") +- "test": Commands that run tests (like "npm test", "cargo test", "pytest") +- "cleanUp": Commands that clean up build artifacts (like "npm run clean", "cargo clean", "rm -rf dist") +- "run": Other commands (like "just dev", "npm run dev", "npm start") + +When categorizing actions, examine what each script actually does: +- If a script runs formatters or auto-fixes issues, it's "format" (even if named "lint") +- If a script only validates/checks without modifying files, it's "check" +- Look at the actual commands in justfile/Makefile targets to determine behavior + +IMPORTANT: Only suggest actions suitable for development environments. Skip: +- Deploy/production commands (like "deploy", "publish", "release") +- CI/CD specific commands +- Docker/container deployment commands +- Cloud infrastructure commands + +Project directory contents: +{file_list} + +Relevant file contents: +{file_contents} + +Return ONLY a JSON array with detected actions. Example: +[ + { + "name": "Install Dependencies", + "command": "npm install", + "actionType": "prerun", + "autoCommit": false, + "source": "package.json" + }, + { + "name": "Test", + "command": "npm test", + "actionType": "test", + "autoCommit": false, + "source": "package.json" + }, + { + "name": "Format", + "command": "just fmt", + "actionType": "format", + "autoCommit": true, + "source": "justfile" + } +]"#; + +/// Detect actions from a project repository using AI +pub async fn detect_actions( + repo_path: &Path, + subpath: Option<&str>, +) -> Result> { + let working_dir = if let Some(sp) = subpath { + repo_path.join(sp) + } else { + repo_path.to_path_buf() + }; + + // Find an available ACP agent + let agent = find_acp_agent() + .ok_or_else(|| anyhow::anyhow!("No AI agent available (goose or claude-code-acp). Please install an ACP-compatible agent to use action detection."))?; + + // Collect information about the project + let file_list = collect_file_list(&working_dir)?; + let file_contents = collect_relevant_files(&working_dir)?; + + // Build the prompt + let prompt = DETECTION_PROMPT_TEMPLATE + .replace("{file_list}", &file_list) + .replace("{file_contents}", &file_contents); + + // Call AI to analyze and suggest actions + let response = run_acp_prompt_raw(&agent, &working_dir, &prompt) + .await + .map_err(|e| anyhow::anyhow!("AI detection failed: {}", e))?; + + // Parse the JSON response + parse_ai_response(&response) +} + +/// Collect a list of files in the directory +fn collect_file_list(dir: &Path) -> Result { + let mut files = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip hidden files and common directories + if name_str.starts_with('.') || name_str == "node_modules" || name_str == "target" { + continue; + } + + if file_type.is_file() { + files.push(name_str.to_string()); + } else if file_type.is_dir() { + files.push(format!("{}/", name_str)); + } + } + } + } + + files.sort(); + Ok(files.join("\n")) +} + +/// Collect contents of relevant build/config files +fn collect_relevant_files(dir: &Path) -> Result { + let relevant_files = [ + "package.json", + "justfile", + "Justfile", + "Makefile", + "makefile", + "Cargo.toml", + "pyproject.toml", + "setup.py", + "tsconfig.json", + ".eslintrc.json", + ".eslintrc.js", + "eslint.config.js", + ".prettierrc", + ".prettierrc.json", + ]; + + let mut contents = Vec::new(); + + for file_name in &relevant_files { + let file_path = dir.join(file_name); + if file_path.exists() { + if let Ok(content) = std::fs::read_to_string(&file_path) { + // Limit file size to avoid token overflow + let truncated = if content.len() > 4000 { + format!("{}... (truncated)", &content[..4000]) + } else { + content + }; + contents.push(format!("=== {} ===\n{}\n", file_name, truncated)); + } + } + } + + if contents.is_empty() { + Ok("No relevant build files found.".to_string()) + } else { + Ok(contents.join("\n")) + } +} + +/// Parse the AI response and extract suggested actions +fn parse_ai_response(response: &str) -> Result> { + // Try to extract JSON from the response + // AI might include explanatory text, so we need to find the JSON array + let json_str = extract_json_array(response)?; + + let actions: Vec = serde_json::from_str(&json_str).map_err(|e| { + anyhow::anyhow!( + "Failed to parse AI response as JSON: {}. Response was: {}", + e, + json_str + ) + })?; + + Ok(actions) +} + +/// Extract JSON array from AI response that might contain extra text +fn extract_json_array(text: &str) -> Result { + // First try to parse the entire response as JSON + if text.trim().starts_with('[') && serde_json::from_str::(text).is_ok() { + return Ok(text.to_string()); + } + + // Look for JSON array in the text + if let Some(start) = text.find('[') { + if let Some(end) = text.rfind(']') { + if end > start { + let json_str = &text[start..=end]; + // Validate it's valid JSON + if serde_json::from_str::(json_str).is_ok() { + return Ok(json_str.to_string()); + } + } + } + } + + Err(anyhow::anyhow!( + "Could not find valid JSON array in AI response. Response was: {}", + text + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_json_array() { + let text = r#"Here are some actions: +[ + {"name": "Test", "command": "npm test", "actionType": "check", "autoCommit": false, "source": "package.json"} +] +That's all!"#; + + let result = extract_json_array(text); + assert!(result.is_ok()); + } + + #[test] + fn test_extract_json_array_clean() { + let text = r#"[{"name": "Test", "command": "npm test", "actionType": "check", "autoCommit": false, "source": "package.json"}]"#; + + let result = extract_json_array(text); + assert!(result.is_ok()); + } +} diff --git a/src-tauri/src/actions/mod.rs b/src-tauri/src/actions/mod.rs new file mode 100644 index 0000000..7929f25 --- /dev/null +++ b/src-tauri/src/actions/mod.rs @@ -0,0 +1,5 @@ +pub mod detector; +pub mod runner; + +pub use detector::{detect_actions, SuggestedAction}; +pub use runner::{ActionOutputEvent, ActionRunner, ActionStatus, ActionStatusEvent}; diff --git a/src-tauri/src/actions/runner.rs b/src-tauri/src/actions/runner.rs new file mode 100644 index 0000000..c3c3332 --- /dev/null +++ b/src-tauri/src/actions/runner.rs @@ -0,0 +1,414 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use tauri::{AppHandle, Emitter}; + +use crate::store::Store; + +/// Event emitted when action output is produced +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionOutputEvent { + pub execution_id: String, + pub chunk: String, + pub stream: String, // "stdout" or "stderr" +} + +/// Event emitted when action status changes +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionStatusEvent { + pub execution_id: String, + pub branch_id: String, + pub action_id: String, + pub action_name: String, + pub status: ActionStatus, + pub exit_code: Option, + pub started_at: i64, + pub completed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ActionStatus { + Running, + Completed, + Failed, + Stopped, +} + +/// Represents a single output chunk with its metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputChunk { + pub chunk: String, + pub stream: String, // "stdout" or "stderr" + pub timestamp: i64, +} + +/// Tracks a running action +struct RunningActionState { + execution_id: String, + action_id: String, + action_name: String, + branch_id: String, + started_at: i64, + #[allow(dead_code)] + child_pid: Option, + output_buffer: Arc>>, +} + +/// Manages action execution +pub struct ActionRunner { + running: Arc>>, +} + +impl Default for ActionRunner { + fn default() -> Self { + Self::new() + } +} + +impl ActionRunner { + pub fn new() -> Self { + Self { + running: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Execute an action in the given worktree directory + pub fn run_action( + &self, + app: AppHandle, + store: Arc, + branch_id: String, + action_id: String, + worktree_path: String, + ) -> Result { + let execution_id = uuid::Uuid::new_v4().to_string(); + + // Get the action from store + let action = store + .get_project_action(&action_id)? + .context("Action not found")?; + + // Determine which shell to use + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + + // Build commands to pipe to shell stdin + // We use stdin instead of -c to ensure directory hooks fire before command execution. + // When using -c, the command runs immediately before hooks can activate Hermit. + let commands = format!("{}\nexit\n", action.command); + + // Use interactive (-i) + login (-l) + stdin (-s) with stdin piping to ensure: + // 1. Interactive mode triggers directory-based hooks (like Hermit's chpwd/precmd) + // 2. Login shell loads the full environment + // 3. -s flag forces shell to read commands from stdin (critical for non-TTY context) + // 4. Stdin commands execute AFTER shell initialization and hook activation + let mut child = Command::new(&shell) + .current_dir(&worktree_path) // Start in target directory to trigger directory hooks + .env_clear() // Clear all inherited environment variables + .env("HOME", std::env::var("HOME").unwrap_or_default()) // Preserve HOME for shell profile loading + .env("USER", std::env::var("USER").unwrap_or_default()) // Preserve USER for shell profile loading + .env("SHELL", &shell) // Preserve SHELL so it knows which shell it is + .arg("-i") // Interactive shell to trigger hooks like chpwd for Hermit + .arg("-l") // Login shell to load profile + .arg("-s") // Force shell to read commands from stdin (required for non-TTY) + .stdin(Stdio::piped()) // Pipe stdin to send commands after initialization + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn action process")?; + + let child_pid = child.id(); + + // Write commands to stdin, flush, and close it + if let Some(mut stdin) = child.stdin.take() { + let commands_clone = commands.clone(); + // Spawn a thread to write to stdin to avoid blocking + thread::spawn(move || { + if let Err(e) = stdin.write_all(commands_clone.as_bytes()) { + eprintln!("Failed to write to stdin: {}", e); + return; + } + // Explicitly flush to ensure commands are sent + if let Err(e) = stdin.flush() { + eprintln!("Failed to flush stdin: {}", e); + } + // stdin is automatically closed when dropped + }); + } + + // Create output buffer + let output_buffer = Arc::new(Mutex::new(Vec::new())); + + // Record the running action + { + let mut running = self.running.lock().unwrap(); + running.insert( + execution_id.clone(), + RunningActionState { + execution_id: execution_id.clone(), + action_id: action_id.clone(), + action_name: action.name.clone(), + branch_id: branch_id.clone(), + started_at: crate::store::now_timestamp(), + child_pid: Some(child_pid), + output_buffer: output_buffer.clone(), + }, + ); + } + + // Emit initial status event + let _ = app.emit( + "action_status", + ActionStatusEvent { + execution_id: execution_id.clone(), + branch_id: branch_id.clone(), + action_id: action_id.clone(), + action_name: action.name.clone(), + status: ActionStatus::Running, + exit_code: None, + started_at: crate::store::now_timestamp(), + completed_at: None, + }, + ); + + // Spawn threads to read stdout and stderr + let exec_id = execution_id.clone(); + let app_clone = app.clone(); + let buffer_clone = output_buffer.clone(); + if let Some(mut stdout) = child.stdout.take() { + thread::spawn(move || { + let mut buffer = [0u8; 1024]; + loop { + match stdout.read(&mut buffer) { + Ok(0) => break, // EOF + Ok(n) => { + // Convert bytes to string, preserving all control characters + let chunk = String::from_utf8_lossy(&buffer[..n]).to_string(); + let timestamp = crate::store::now_timestamp(); + + // Store in buffer + { + let mut buf = buffer_clone.lock().unwrap(); + buf.push(OutputChunk { + chunk: chunk.clone(), + stream: "stdout".to_string(), + timestamp, + }); + } + + // Emit event + let _ = app_clone.emit( + "action_output", + ActionOutputEvent { + execution_id: exec_id.clone(), + chunk, + stream: "stdout".to_string(), + }, + ); + } + Err(_) => break, + } + } + }); + } + + let exec_id = execution_id.clone(); + let app_clone = app.clone(); + let buffer_clone = output_buffer.clone(); + if let Some(mut stderr) = child.stderr.take() { + thread::spawn(move || { + let mut buffer = [0u8; 1024]; + loop { + match stderr.read(&mut buffer) { + Ok(0) => break, // EOF + Ok(n) => { + // Convert bytes to string, preserving all control characters + let chunk = String::from_utf8_lossy(&buffer[..n]).to_string(); + let timestamp = crate::store::now_timestamp(); + + // Store in buffer + { + let mut buf = buffer_clone.lock().unwrap(); + buf.push(OutputChunk { + chunk: chunk.clone(), + stream: "stderr".to_string(), + timestamp, + }); + } + + // Emit event + let _ = app_clone.emit( + "action_output", + ActionOutputEvent { + execution_id: exec_id.clone(), + chunk, + stream: "stderr".to_string(), + }, + ); + } + Err(_) => break, + } + } + }); + } + + // Spawn thread to wait for completion + let exec_id = execution_id.clone(); + let running_clone = self.running.clone(); + let app_clone = app.clone(); + let _store_clone = store.clone(); + let branch_id_clone = branch_id.clone(); + let worktree_path_clone = worktree_path.clone(); + let auto_commit = action.auto_commit; + let action_name = action.name.clone(); + + thread::spawn(move || { + let exit_status = child.wait(); + let exit_code = exit_status.as_ref().ok().and_then(|s| s.code()); + let completed_at = crate::store::now_timestamp(); + + // Remove from running actions + { + let mut running = running_clone.lock().unwrap(); + running.remove(&exec_id); + } + + let success = exit_status.as_ref().map(|s| s.success()).unwrap_or(false); + + // Emit completion status + let _ = app_clone.emit( + "action_status", + ActionStatusEvent { + execution_id: exec_id.clone(), + branch_id: branch_id_clone.clone(), + action_id: action_id.clone(), + action_name: action_name.clone(), + status: if success { + ActionStatus::Completed + } else { + ActionStatus::Failed + }, + exit_code, + started_at: crate::store::now_timestamp(), // Will be overridden by frontend + completed_at: Some(completed_at), + }, + ); + + // If auto_commit is enabled and action succeeded, commit changes + if auto_commit && success { + if let Err(e) = Self::auto_commit_changes(&worktree_path_clone, &action_name) { + eprintln!("Failed to auto-commit changes: {}", e); + } else { + // Emit event to notify frontend of the commit + let _ = app_clone.emit( + "action_auto_commit", + serde_json::json!({ + "executionId": exec_id, + "branchId": branch_id_clone, + "actionName": action_name, + }), + ); + } + } + }); + + Ok(execution_id) + } + + /// Auto-commit changes after a successful action + fn auto_commit_changes(worktree_path: &str, action_name: &str) -> Result<()> { + // Check if there are any changes + let status = Command::new("git") + .arg("diff") + .arg("--exit-code") + .current_dir(worktree_path) + .status()?; + + // If exit code is 0, no changes exist + if status.success() { + return Ok(()); + } + + // Stage all changes + Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .status() + .context("Failed to stage changes")?; + + // Commit with action name + let commit_message = format!("chore: {}", action_name); + Command::new("git") + .args(["commit", "-m", &commit_message]) + .current_dir(worktree_path) + .status() + .context("Failed to commit changes")?; + + Ok(()) + } + + /// Stop a running action + pub fn stop_action(&self, execution_id: &str) -> Result<()> { + let state = { + let mut running = self.running.lock().unwrap(); + running.remove(execution_id) + }; + + if let Some(state) = state { + if let Some(pid) = state.child_pid { + // Kill the process + #[cfg(unix)] + { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + + #[cfg(windows)] + { + Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status()?; + } + } + } + + Ok(()) + } + + /// Get all running actions for a branch + pub fn get_running_actions(&self, branch_id: &str) -> Vec { + let running = self.running.lock().unwrap(); + running + .values() + .filter(|state| state.branch_id == branch_id) + .map(|state| ActionStatusEvent { + execution_id: state.execution_id.clone(), + branch_id: state.branch_id.clone(), + action_id: state.action_id.clone(), + action_name: state.action_name.clone(), + status: ActionStatus::Running, + exit_code: None, + started_at: state.started_at, + completed_at: None, + }) + .collect() + } + + /// Get buffered output for an execution + pub fn get_buffered_output(&self, execution_id: &str) -> Option> { + let running = self.running.lock().unwrap(); + if let Some(state) = running.get(execution_id) { + let buffer = state.output_buffer.lock().unwrap(); + Some(buffer.clone()) + } else { + None + } + } +} diff --git a/src-tauri/src/ai/client.rs b/src-tauri/src/ai/client.rs index 1d1091f..b35806d 100644 --- a/src-tauri/src/ai/client.rs +++ b/src-tauri/src/ai/client.rs @@ -557,19 +557,9 @@ pub async fn run_acp_prompt( prompt: &str, ) -> Result { // No streaming, no events emitted — internal_session_id is unused - let result = run_acp_prompt_internal( - agent, - working_dir, - prompt, - None, - None, - None, - "", - true, - None, - None, - ) - .await?; + let result = + run_acp_prompt_internal(agent, working_dir, prompt, None, None, "", true, None, None) + .await?; Ok(result.response) } @@ -588,7 +578,6 @@ pub async fn run_acp_prompt_raw( prompt, None, None, - None, "", false, None, @@ -614,7 +603,6 @@ pub async fn run_acp_prompt_with_session( agent, working_dir, prompt, - None, session_id, None, "", @@ -650,38 +638,6 @@ pub async fn run_acp_prompt_streaming( agent, working_dir, prompt, - None, // No images - acp_session_id, - Some(app_handle), - internal_session_id, - true, - buffer_callback, - cancellation, - ) - .await -} - -/// Run a prompt with images through ACP with streaming events emitted to frontend -/// -/// Same as `run_acp_prompt_streaming` but accepts optional image attachments. -/// Images are sent as ContentBlock::Image in the prompt request. -#[allow(clippy::too_many_arguments)] -pub async fn run_acp_prompt_streaming_with_images( - agent: &AcpAgent, - working_dir: &Path, - prompt: &str, - images: Option<&[crate::ImageAttachment]>, - acp_session_id: Option<&str>, - internal_session_id: &str, - app_handle: tauri::AppHandle, - buffer_callback: Option) + Send + Sync>>, - cancellation: Option>, -) -> Result { - run_acp_prompt_internal( - agent, - working_dir, - prompt, - images, acp_session_id, Some(app_handle), internal_session_id, @@ -698,7 +654,6 @@ async fn run_acp_prompt_internal( agent: &AcpAgent, working_dir: &Path, prompt: &str, - images: Option<&[crate::ImageAttachment]>, acp_session_id: Option<&str>, app_handle: Option, internal_session_id: &str, @@ -711,7 +666,6 @@ async fn run_acp_prompt_internal( let agent_args: Vec = agent.acp_args().iter().map(|s| s.to_string()).collect(); let working_dir = working_dir.to_path_buf(); let prompt = prompt.to_string(); - let images_owned: Option> = images.map(|imgs| imgs.to_vec()); let acp_session_id = acp_session_id.map(|s| s.to_string()); let internal_session_id = internal_session_id.to_string(); @@ -733,7 +687,6 @@ async fn run_acp_prompt_internal( &agent_args, &working_dir, &prompt, - images_owned.as_deref(), acp_session_id.as_deref(), app_handle, &internal_session_id, @@ -756,7 +709,6 @@ async fn run_acp_session_inner( agent_args: &[String], working_dir: &Path, prompt: &str, - images: Option<&[crate::ImageAttachment]>, existing_session_id: Option<&str>, app_handle: Option, internal_session_id: &str, @@ -893,20 +845,11 @@ async fn run_acp_session_inner( prompt.to_string() }; - // Build content blocks: text prompt + optional images - let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(full_prompt))]; - - // Add image blocks if provided - if let Some(imgs) = images { - for img in imgs { - content_blocks.push(AcpContentBlock::Image( - agent_client_protocol::ImageContent::new(img.data.clone(), img.mime_type.clone()), - )); - } - } - - // Send the prompt with content blocks - let prompt_request = PromptRequest::new(session_id.clone(), content_blocks); + // Send the prompt + let prompt_request = PromptRequest::new( + session_id.clone(), + vec![AcpContentBlock::Text(TextContent::new(full_prompt))], + ); let prompt_result = connection.prompt(prompt_request).await; diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 6d58f16..6c188d1 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -26,8 +26,8 @@ pub mod session; // Re-export core ACP client functionality pub use client::{ discover_acp_providers, find_acp_agent, find_acp_agent_by_id, run_acp_prompt, - run_acp_prompt_raw, run_acp_prompt_streaming, run_acp_prompt_streaming_with_images, - run_acp_prompt_with_session, AcpAgent, AcpPromptResult, AcpProviderInfo, + run_acp_prompt_raw, run_acp_prompt_streaming, run_acp_prompt_with_session, AcpAgent, + AcpPromptResult, AcpProviderInfo, }; // Re-export session manager types diff --git a/src-tauri/src/ai/session.rs b/src-tauri/src/ai/session.rs index 297977a..5c85114 100644 --- a/src-tauri/src/ai/session.rs +++ b/src-tauri/src/ai/session.rs @@ -293,12 +293,7 @@ impl SessionManager { } /// Send a prompt to a session - pub async fn send_prompt( - &self, - session_id: &str, - prompt: String, - images: Option>, - ) -> Result<(), String> { + pub async fn send_prompt(&self, session_id: &str, prompt: String) -> Result<(), String> { // Get or create live session let session_arc = self.get_or_create_live_session(session_id).await?; @@ -352,32 +347,17 @@ impl SessionManager { tokio::spawn(async move { // Run the ACP prompt with streaming - let result = if let Some(ref imgs) = images { - client::run_acp_prompt_streaming_with_images( - &agent, - &working_dir, - &prompt, - Some(imgs.as_slice()), - acp_session_id.as_deref(), - &session_id_owned, - app_handle.clone(), - Some(buffer_callback), - Some(cancellation.clone()), - ) - .await - } else { - client::run_acp_prompt_streaming( - &agent, - &working_dir, - &prompt, - acp_session_id.as_deref(), - &session_id_owned, - app_handle.clone(), - Some(buffer_callback), - Some(cancellation.clone()), - ) - .await - }; + let result = client::run_acp_prompt_streaming( + &agent, + &working_dir, + &prompt, + acp_session_id.as_deref(), + &session_id_owned, + app_handle.clone(), + Some(buffer_callback), + Some(cancellation.clone()), + ) + .await; // Update session and persist based on result let mut session = session_arc_clone.write().await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6762e86..00c01d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ //! This module provides the bridge between the frontend and the git/github modules. //! Supports CLI arguments: `staged [path]` opens the app with the specified directory. +pub mod actions; pub mod ai; pub mod git; pub mod project; @@ -841,73 +842,6 @@ async fn analyze_diff( ai::analysis::analyze_diff(&path, &spec, provider.as_deref()).await } -/// Maximum size for base64-encoded image data (10MB) -const MAX_IMAGE_SIZE: usize = 10 * 1024 * 1024; - -/// Maximum number of images per request -const MAX_IMAGE_COUNT: usize = 5; - -/// Allowed MIME types for image attachments -const ALLOWED_MIME_TYPES: &[&str] = &[ - "image/png", - "image/jpeg", - "image/jpg", - "image/gif", - "image/webp", -]; - -/// An image attachment for AI prompts -#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ImageAttachment { - /// Base64-encoded image data - pub data: String, - /// MIME type (e.g., "image/png", "image/jpeg") - pub mime_type: String, -} - -impl ImageAttachment { - /// Validates the image attachment for size and format - pub fn validate(&self) -> Result<(), String> { - if self.data.len() > MAX_IMAGE_SIZE { - return Err(format!( - "Image too large: {} bytes (max {} bytes)", - self.data.len(), - MAX_IMAGE_SIZE - )); - } - - if !ALLOWED_MIME_TYPES.contains(&self.mime_type.as_str()) { - return Err(format!( - "Unsupported image format: {}. Allowed formats: {}", - self.mime_type, - ALLOWED_MIME_TYPES.join(", ") - )); - } - - Ok(()) - } -} - -/// Validates a collection of image attachments -fn validate_images(images: &Option>) -> Result<(), String> { - if let Some(imgs) = images { - if imgs.len() > MAX_IMAGE_COUNT { - return Err(format!( - "Too many images: {} (max {})", - imgs.len(), - MAX_IMAGE_COUNT - )); - } - - for (i, img) in imgs.iter().enumerate() { - img.validate() - .map_err(|e| format!("Image {}: {}", i + 1, e))?; - } - } - Ok(()) -} - /// Response from send_agent_prompt including session ID for continuity. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -961,8 +895,6 @@ async fn send_agent_prompt( /// - "session-complete": Finalized transcript when done /// - "session-error": Error information if the session fails /// -/// Supports optional image attachments for multimodal prompts. -/// /// Returns the same response as send_agent_prompt for compatibility. #[tauri::command(rename_all = "camelCase")] async fn send_agent_prompt_streaming( @@ -971,10 +903,7 @@ async fn send_agent_prompt_streaming( prompt: String, session_id: Option, provider: Option, - images: Option>, ) -> Result { - validate_images(&images)?; - let agent = if let Some(provider_id) = provider { ai::find_acp_agent_by_id(&provider_id).ok_or_else(|| { format!( @@ -991,11 +920,10 @@ async fn send_agent_prompt_streaming( // Legacy path: no internal session ID, use ACP session ID or "legacy" as fallback let internal_id = session_id.as_deref().unwrap_or("legacy"); - let result = ai::run_acp_prompt_streaming_with_images( + let result = ai::run_acp_prompt_streaming( &agent, &path, &prompt, - images.as_deref(), session_id.as_deref(), internal_id, app_handle, @@ -1054,10 +982,8 @@ async fn send_prompt( state: State<'_, Arc>, session_id: String, prompt: String, - images: Option>, ) -> Result<(), String> { - validate_images(&images)?; - state.send_prompt(&session_id, prompt, images).await + state.send_prompt(&session_id, prompt).await } /// Update session title. @@ -1722,7 +1648,7 @@ async fn create_branch( let store = state.inner().clone(); // Run blocking git operations on a separate thread - tauri::async_runtime::spawn_blocking(move || { + let branch = tauri::async_runtime::spawn_blocking(move || { let repo = Path::new(&repo_path); // Use provided base branch or detect the default @@ -1761,7 +1687,49 @@ async fn create_branch( Ok(branch) }) .await - .map_err(|e| format!("Task failed: {e}"))? + .map_err(|e| format!("Task failed: {e}"))??; + + Ok(branch) +} + +/// Run prerun actions for a branch. +/// This is called separately after branch creation so the UI can show running actions. +#[tauri::command(rename_all = "camelCase")] +async fn run_prerun_actions( + app: tauri::AppHandle, + state: State<'_, Arc>, + runner: State<'_, Arc>, + branch_id: String, +) -> Result<(), String> { + let store = state.inner().clone(); + let runner = runner.inner().clone(); + + // Get the branch + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "Branch not found".to_string())?; + + // Get prerun actions for this project + let prerun_actions = store + .list_project_actions_by_type(&branch.project_id, crate::store::ActionType::Prerun) + .map_err(|e| e.to_string())?; + + // Execute each prerun action in order + for action in prerun_actions { + if let Err(e) = runner.run_action( + app.clone(), + store.clone(), + branch.id.clone(), + action.id.clone(), + branch.worktree_path.clone(), + ) { + eprintln!("Failed to run prerun action '{}': {}", action.name, e); + // Continue with other actions even if one fails + } + } + + Ok(()) } /// Create a new branch from an existing GitHub PR. @@ -1780,7 +1748,7 @@ async fn create_branch_from_pr( let store = state.inner().clone(); // Run blocking git operations on a separate thread - tauri::async_runtime::spawn_blocking(move || { + let branch = tauri::async_runtime::spawn_blocking(move || { let repo = Path::new(&repo_path); // Create the worktree from the PR @@ -1813,7 +1781,9 @@ async fn create_branch_from_pr( Ok(branch) }) .await - .map_err(|e| format!("Task failed: {e}"))? + .map_err(|e| format!("Task failed: {e}"))??; + + Ok(branch) } /// List git branches (local and remote) for base branch selection. @@ -2002,10 +1972,7 @@ async fn start_branch_session( branch_id: String, user_prompt: String, agent_id: Option, - images: Option>, ) -> Result { - validate_images(&images)?; - // Get the branch to find the worktree path let branch = state .get_branch(&branch_id) @@ -2043,7 +2010,7 @@ async fn start_branch_session( // Send the full prompt (with context) to the AI if let Err(e) = session_manager - .send_prompt(&ai_session_id, full_prompt, images) + .send_prompt(&ai_session_id, full_prompt) .await { // Clean up on failure @@ -2399,10 +2366,7 @@ async fn restart_branch_session( session_manager: State<'_, Arc>, branch_session_id: String, full_prompt: String, - images: Option>, ) -> Result { - validate_images(&images)?; - // Get the old session to retrieve the branch ID and prompt let old_session = state .get_branch_session(&branch_session_id) @@ -2438,7 +2402,7 @@ async fn restart_branch_session( // Send the prompt to the AI if let Err(e) = session_manager - .send_prompt(&ai_session_id, full_prompt, images) + .send_prompt(&ai_session_id, full_prompt) .await { // Clean up on failure @@ -2625,10 +2589,7 @@ async fn start_branch_note( title: String, description: String, agent_id: Option, - images: Option>, ) -> Result { - validate_images(&images)?; - // Get the branch to find the worktree path let branch = state .get_branch(&branch_id) @@ -2669,7 +2630,7 @@ async fn start_branch_note( // Send the full prompt (with context) to the AI if let Err(e) = session_manager - .send_prompt(&ai_session_id, full_prompt, images) + .send_prompt(&ai_session_id, full_prompt) .await { // Clean up on failure @@ -2842,25 +2803,6 @@ fn extract_text_from_assistant_content(content: &str) -> String { use store::GitProject; -/// Canonicalize a repo path to ensure consistent path comparison. -/// This resolves symlinks and normalizes the path (including case on case-insensitive filesystems). -/// Returns the original path if canonicalization fails. -fn canonicalize_repo_path(repo_path: &str) -> String { - std::path::Path::new(repo_path) - .canonicalize() - .ok() - .and_then(|p| p.to_str().map(|s| s.to_string())) - .unwrap_or_else(|| repo_path.to_string()) -} - -/// Clean and normalize a subpath by trimming whitespace and removing leading/trailing slashes. -/// Returns None for empty strings after cleaning. -fn clean_subpath(subpath: Option) -> Option { - subpath - .map(|s| s.trim().trim_matches('/').to_string()) - .filter(|s| !s.is_empty()) -} - /// Create a new git project. /// If a project already exists for the repo_path, returns an error. #[tauri::command(rename_all = "camelCase")] @@ -2869,31 +2811,28 @@ fn create_git_project( repo_path: String, subpath: Option, ) -> Result { - // Canonicalize the repo path to ensure consistent comparison - let canonical_repo_path = canonicalize_repo_path(&repo_path); - - // Clean and normalize the subpath - let cleaned_subpath = clean_subpath(subpath); + // Normalize subpath: empty string becomes None + let subpath = subpath.filter(|s| !s.is_empty()); // Check if project already exists for this repo+subpath if state - .get_git_project_by_repo_and_subpath(&canonical_repo_path, cleaned_subpath.as_deref()) + .get_git_project_by_repo_and_subpath(&repo_path, subpath.as_deref()) .map_err(|e| e.to_string())? .is_some() { - let repo_name = std::path::Path::new(&canonical_repo_path) + let repo_name = std::path::Path::new(&repo_path) .file_name() .and_then(|n| n.to_str()) - .unwrap_or(&canonical_repo_path); + .unwrap_or(&repo_path); - return Err(match &cleaned_subpath { + return Err(match &subpath { Some(sp) => format!("A project already exists for {repo_name} with subpath '{sp}'"), None => format!("A project already exists for {repo_name} with no subpath"), }); } - let mut project = GitProject::new(&canonical_repo_path); - if let Some(sp) = cleaned_subpath { + let mut project = GitProject::new(&repo_path); + if let Some(sp) = subpath { project = project.with_subpath(sp); } @@ -2920,10 +2859,8 @@ fn get_git_project_by_repo( state: State<'_, Arc>, repo_path: String, ) -> Result, String> { - // Canonicalize the repo path to ensure consistent lookup - let canonical_repo_path = canonicalize_repo_path(&repo_path); state - .get_git_project_by_repo(&canonical_repo_path) + .get_git_project_by_repo(&repo_path) .map_err(|e| e.to_string()) } @@ -2940,10 +2877,8 @@ fn update_git_project( project_id: String, subpath: Option, ) -> Result<(), String> { - // Clean and normalize the subpath - let cleaned_subpath = clean_subpath(subpath); state - .update_git_project(&project_id, cleaned_subpath.as_deref()) + .update_git_project(&project_id, subpath.as_deref()) .map_err(|e| e.to_string()) } @@ -2956,6 +2891,193 @@ fn delete_git_project(state: State<'_, Arc>, project_id: String) -> Resul .map_err(|e| e.to_string()) } +/// Get or create a git project for a repo_path. +/// If no project exists, creates one for the given repo. +#[tauri::command(rename_all = "camelCase")] +fn get_or_create_git_project( + state: State<'_, Arc>, + repo_path: String, +) -> Result { + // Check if project already exists + if let Some(existing) = state + .get_git_project_by_repo(&repo_path) + .map_err(|e| e.to_string())? + { + return Ok(existing); + } + + let project = GitProject::new(&repo_path); + state + .create_git_project(&project) + .map_err(|e| e.to_string())?; + Ok(project) +} + +// ============================================================================= +// Project Action Commands +// ============================================================================= + +/// List all actions for a project +#[tauri::command(rename_all = "camelCase")] +fn list_project_actions( + state: State<'_, Arc>, + project_id: String, +) -> Result, String> { + state + .list_project_actions(&project_id) + .map_err(|e| e.to_string()) +} + +/// Create a new project action +#[tauri::command(rename_all = "camelCase")] +fn create_project_action( + state: State<'_, Arc>, + project_id: String, + name: String, + command: String, + action_type: String, + sort_order: i32, + auto_commit: bool, +) -> Result { + let action_type = store::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {}", action_type))?; + + let action = store::ProjectAction::new(project_id, name, command, action_type, sort_order) + .with_auto_commit(auto_commit); + + state + .create_project_action(&action) + .map_err(|e| e.to_string())?; + + Ok(action) +} + +/// Update a project action +#[tauri::command(rename_all = "camelCase")] +fn update_project_action( + state: State<'_, Arc>, + action_id: String, + name: String, + command: String, + action_type: String, + sort_order: i32, + auto_commit: bool, +) -> Result<(), String> { + let action_type = store::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {}", action_type))?; + + // Get existing action + let mut action = state + .get_project_action(&action_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Action not found: {}", action_id))?; + + // Update fields + action.name = name; + action.command = command; + action.action_type = action_type; + action.sort_order = sort_order; + action.auto_commit = auto_commit; + + state + .update_project_action(&action) + .map_err(|e| e.to_string()) +} + +/// Delete a project action +#[tauri::command(rename_all = "camelCase")] +fn delete_project_action(state: State<'_, Arc>, action_id: String) -> Result<(), String> { + state + .delete_project_action(&action_id) + .map_err(|e| e.to_string()) +} + +/// Reorder project actions +#[tauri::command(rename_all = "camelCase")] +fn reorder_project_actions( + state: State<'_, Arc>, + action_ids: Vec, +) -> Result<(), String> { + state + .reorder_project_actions(&action_ids) + .map_err(|e| e.to_string()) +} + +/// Detect actions for a project using AI +#[tauri::command(rename_all = "camelCase")] +async fn detect_project_actions( + state: State<'_, Arc>, + project_id: String, +) -> Result, String> { + // Get the project + let project = state + .get_git_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {}", project_id))?; + + // Detect actions using AI + let repo_path = std::path::Path::new(&project.repo_path); + actions::detect_actions(repo_path, project.subpath.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// Run an action on a branch +#[tauri::command(rename_all = "camelCase")] +fn run_branch_action( + state: State<'_, Arc>, + runner: State<'_, Arc>, + app: tauri::AppHandle, + branch_id: String, + action_id: String, +) -> Result { + // Get the branch to find its worktree path + let branch = state + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {}", branch_id))?; + + // Run the action + runner + .run_action( + app, + state.inner().clone(), + branch_id, + action_id, + branch.worktree_path, + ) + .map_err(|e| e.to_string()) +} + +/// Stop a running action +#[tauri::command(rename_all = "camelCase")] +fn stop_branch_action( + runner: State<'_, Arc>, + execution_id: String, +) -> Result<(), String> { + runner.stop_action(&execution_id).map_err(|e| e.to_string()) +} + +/// Get all running actions for a branch +#[tauri::command(rename_all = "camelCase")] +fn get_running_branch_actions( + runner: State<'_, Arc>, + branch_id: String, +) -> Result, String> { + Ok(runner.get_running_actions(&branch_id)) +} + +/// Get buffered output for an execution +#[tauri::command(rename_all = "camelCase")] +fn get_action_output_buffer( + runner: State<'_, Arc>, + execution_id: String, +) -> Result, String> { + runner + .get_buffered_output(&execution_id) + .ok_or_else(|| format!("No output buffer found for execution: {}", execution_id)) +} + // ============================================================================= // Theme Commands // ============================================================================= @@ -3402,9 +3524,14 @@ pub fn run() { app.manage(store.clone()); // Initialize the session manager - let session_manager = Arc::new(SessionManager::new(app.handle().clone(), store)); + let session_manager = + Arc::new(SessionManager::new(app.handle().clone(), store.clone())); app.manage(session_manager); + // Initialize the action runner + let action_runner = Arc::new(actions::ActionRunner::new()); + app.manage(action_runner); + // Initialize the watcher handle (spawns background thread) let watcher = WatcherHandle::new(app.handle().clone()); app.manage(watcher); @@ -3542,6 +3669,19 @@ pub fn run() { list_git_projects, update_git_project, delete_git_project, + get_or_create_git_project, + // Project action commands + list_project_actions, + create_project_action, + update_project_action, + delete_project_action, + reorder_project_actions, + detect_project_actions, + run_branch_action, + run_prerun_actions, + stop_branch_action, + get_running_branch_actions, + get_action_output_buffer, // Theme commands get_custom_themes, read_custom_theme, diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index c884853..53b88b3 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -245,6 +245,114 @@ impl ArtifactData { } } +/// The type of a project action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ActionType { + Prerun, + Run, + Build, + Format, + Check, + #[serde(rename = "cleanUp")] + CleanUp, + Test, +} + +impl ActionType { + pub fn as_str(&self) -> &'static str { + match self { + ActionType::Prerun => "prerun", + ActionType::Run => "run", + ActionType::Build => "build", + ActionType::Format => "format", + ActionType::Check => "check", + ActionType::CleanUp => "cleanUp", + ActionType::Test => "test", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "prerun" => Some(ActionType::Prerun), + "run" => Some(ActionType::Run), + "build" => Some(ActionType::Build), + "format" => Some(ActionType::Format), + "check" => Some(ActionType::Check), + "cleanUp" => Some(ActionType::CleanUp), + "test" => Some(ActionType::Test), + _ => None, + } + } +} + +/// A configurable action that can be run on a project. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectAction { + pub id: String, + pub project_id: String, + pub name: String, + pub command: String, + pub action_type: ActionType, + pub sort_order: i32, + pub auto_commit: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl ProjectAction { + pub fn new( + project_id: impl Into, + name: impl Into, + command: impl Into, + action_type: ActionType, + sort_order: i32, + ) -> Self { + let now = now_timestamp(); + Self { + id: uuid::Uuid::new_v4().to_string(), + project_id: project_id.into(), + name: name.into(), + command: command.into(), + action_type, + sort_order, + auto_commit: false, + created_at: now, + updated_at: now, + } + } + + pub fn with_auto_commit(mut self, auto_commit: bool) -> Self { + self.auto_commit = auto_commit; + self + } + + /// Create a ProjectAction from a database row. + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let action_type_str: String = row.get(4)?; + let action_type = ActionType::parse(&action_type_str).ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 4, + "action_type".to_string(), + rusqlite::types::Type::Text, + ) + })?; + + Ok(Self { + id: row.get(0)?, + project_id: row.get(1)?, + name: row.get(2)?, + command: row.get(3)?, + action_type, + sort_order: row.get(5)?, + auto_commit: row.get::<_, i32>(6)? != 0, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) + } +} + /// The persistent output of AI work. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -786,6 +894,21 @@ impl Store { ); CREATE INDEX IF NOT EXISTS idx_git_projects_repo ON git_projects(repo_path); + + CREATE TABLE IF NOT EXISTS project_actions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES git_projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + command TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + auto_commit INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_project_actions_project ON project_actions(project_id); + CREATE INDEX IF NOT EXISTS idx_project_actions_type ON project_actions(project_id, action_type); "#, )?; @@ -878,9 +1001,10 @@ impl Store { for (branch_id, repo_path) in branch_repos { // Check if a project exists for this repo_path + // Prefer projects with subpath IS NULL, but accept any project for this repo let project_id: Option = conn .query_row( - "SELECT id FROM git_projects WHERE repo_path = ?1 AND subpath IS NULL LIMIT 1", + "SELECT id FROM git_projects WHERE repo_path = ?1 ORDER BY subpath IS NULL DESC LIMIT 1", params![&repo_path], |row| row.get(0), ) @@ -908,6 +1032,54 @@ impl Store { } } + // Fix branches that might be pointing to auto-created empty projects + // when a user-created project with actions exists for the same repo. + // This handles the case where the previous migration created projects with empty names + // while the user had already created a project with a name/subpath for that repo. + { + // Find all projects with no name that were auto-created + let mut stmt = conn.prepare( + "SELECT id, repo_path FROM git_projects WHERE name = '' AND subpath IS NULL", + )?; + let empty_projects: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + drop(stmt); + + for (empty_project_id, repo_path) in empty_projects { + // Check if there's a better project for this repo (one with a name or subpath) + let better_project_id: Option = conn + .query_row( + "SELECT id FROM git_projects WHERE repo_path = ?1 AND (name != '' OR subpath IS NOT NULL) LIMIT 1", + params![&repo_path], + |row| row.get(0), + ) + .optional()?; + + if let Some(better_id) = better_project_id { + // Update branches to use the better project + conn.execute( + "UPDATE branches SET project_id = ?1 WHERE project_id = ?2", + params![&better_id, &empty_project_id], + )?; + + // Delete the empty project if it has no branches + let branch_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM branches WHERE project_id = ?1", + params![&empty_project_id], + |row| row.get(0), + )?; + + if branch_count == 0 { + conn.execute( + "DELETE FROM git_projects WHERE id = ?1", + params![&empty_project_id], + )?; + } + } + } + } + Ok(()) } @@ -1951,6 +2123,121 @@ impl Store { )?; Ok(()) } + + // ================================================================================ + // Project Actions + // ================================================================================ + + /// Create a new project action + pub fn create_project_action(&self, action: &ProjectAction) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO project_actions (id, project_id, name, command, action_type, sort_order, auto_commit, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + &action.id, + &action.project_id, + &action.name, + &action.command, + action.action_type.as_str(), + action.sort_order, + if action.auto_commit { 1 } else { 0 }, + action.created_at, + action.updated_at, + ], + )?; + Ok(()) + } + + /// Get a project action by ID + pub fn get_project_action(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, project_id, name, command, action_type, sort_order, auto_commit, created_at, updated_at + FROM project_actions WHERE id = ?1", + params![id], + ProjectAction::from_row, + ) + .optional() + .map_err(Into::into) + } + + /// List all actions for a project, ordered by sort_order + pub fn list_project_actions(&self, project_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, name, command, action_type, sort_order, auto_commit, created_at, updated_at + FROM project_actions WHERE project_id = ?1 ORDER BY sort_order ASC", + )?; + let actions = stmt + .query_map(params![project_id], ProjectAction::from_row)? + .collect::, _>>()?; + Ok(actions) + } + + /// List actions for a project filtered by type + pub fn list_project_actions_by_type( + &self, + project_id: &str, + action_type: ActionType, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, name, command, action_type, sort_order, auto_commit, created_at, updated_at + FROM project_actions WHERE project_id = ?1 AND action_type = ?2 ORDER BY sort_order ASC", + )?; + let actions = stmt + .query_map( + params![project_id, action_type.as_str()], + ProjectAction::from_row, + )? + .collect::, _>>()?; + Ok(actions) + } + + /// Update a project action + pub fn update_project_action(&self, action: &ProjectAction) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = now_timestamp(); + conn.execute( + "UPDATE project_actions + SET name = ?1, command = ?2, action_type = ?3, sort_order = ?4, auto_commit = ?5, updated_at = ?6 + WHERE id = ?7", + params![ + &action.name, + &action.command, + action.action_type.as_str(), + action.sort_order, + if action.auto_commit { 1 } else { 0 }, + now, + &action.id, + ], + )?; + Ok(()) + } + + /// Delete a project action + pub fn delete_project_action(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM project_actions WHERE id = ?1", params![id])?; + Ok(()) + } + + /// Reorder project actions by updating their sort_order values + pub fn reorder_project_actions(&self, action_ids: &[String]) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let tx = conn.unchecked_transaction()?; + + for (index, action_id) in action_ids.iter().enumerate() { + tx.execute( + "UPDATE project_actions SET sort_order = ?1, updated_at = ?2 WHERE id = ?3", + params![index as i32, now_timestamp(), action_id], + )?; + } + + tx.commit()?; + Ok(()) + } } // ============================================================================= diff --git a/src/App.svelte b/src/App.svelte index 1e86454..b86b5cb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -518,40 +518,36 @@ // Update repo state openRepo(repoPath); - // Check if a project exists for this repo - const project = await branchService.getGitProjectByRepo(repoPath); - - if (project) { - // Save current tab state before creating new one - syncGlobalToTab(); + // Save current tab state before creating new one + syncGlobalToTab(); - const repoName = extractRepoName(repoPath); + // Get or create a project for this repo + const project = await branchService.getOrCreateGitProject(repoPath); + const repoName = extractRepoName(repoPath); - addTab( - project.id, - repoPath, - repoName, - project.subpath, - createDiffState, - createCommentsState, - createDiffSelection, - createAgentState, - createReferenceFilesState - ); + addTab( + project.id, + repoPath, + repoName, + project.subpath, + createDiffState, + createCommentsState, + createDiffSelection, + createAgentState, + createReferenceFilesState + ); - // Start watching the new repo (idempotent - won't restart if already watching) - watchRepo(repoPath); + // Start watching the new repo (idempotent - won't restart if already watching) + watchRepo(repoPath); - // Sync to the new tab - syncTabToGlobal(); + // Sync to the new tab + syncTabToGlobal(); - // Initialize the new tab - const newTab = getActiveTab(); - if (newTab) { - await initializeNewTab(newTab); - } + // Initialize the new tab + const newTab = getActiveTab(); + if (newTab) { + await initializeNewTab(newTab); } - // If no project exists, user must add it via Add Project button first } // Get current diff - check reference files first @@ -725,49 +721,44 @@ const repoPath = await initRepoState(); if (repoPath) { - // Check if a project exists for this repo - const project = await branchService.getGitProjectByRepo(repoPath); - - if (project) { - // Check if we already have a tab for this project - const existingTabIndex = windowState.tabs.findIndex((t) => t.projectId === project.id); - - if (existingTabIndex >= 0) { - // Switch to existing tab for this project - switchTab(existingTabIndex); - } else { - // Create new tab for the CLI path - const repoName = extractRepoName(repoPath); - addTab( - project.id, - repoPath, - repoName, - project.subpath, - createDiffState, - createCommentsState, - createDiffSelection, - createAgentState, - createReferenceFilesState - ); - } - - // Sync the active tab to global state - syncTabToGlobal(); + // Get or create a project for this repo + const project = await branchService.getOrCreateGitProject(repoPath); - // Watch the active tab's repo - const tab = getActiveTab(); - if (tab) { - await watchRepo(tab.repoPath); + // Check if we already have a tab for this project + const existingTabIndex = windowState.tabs.findIndex((t) => t.projectId === project.id); - // Initialize the active tab - await initializeNewTab(tab); - } + if (existingTabIndex >= 0) { + // Switch to existing tab for this project + switchTab(existingTabIndex); + } else { + // Create new tab for the CLI path + const repoName = extractRepoName(repoPath); + addTab( + project.id, + repoPath, + repoName, + project.subpath, + createDiffState, + createCommentsState, + createDiffSelection, + createAgentState, + createReferenceFilesState + ); } - // If no project exists, don't create one - user must use Add Project button - } - // Use restored tabs if available - if (windowState.tabs.length > 0) { + // Sync the active tab to global state + syncTabToGlobal(); + + // Watch the active tab's repo + const tab = getActiveTab(); + if (tab) { + await watchRepo(tab.repoPath); + + // Initialize the active tab + await initializeNewTab(tab); + } + } else if (windowState.tabs.length > 0) { + // No CLI path but we have restored tabs - use them syncTabToGlobal(); const tab = getActiveTab(); diff --git a/src/lib/ActionOutputModal.svelte b/src/lib/ActionOutputModal.svelte new file mode 100644 index 0000000..d5e095c --- /dev/null +++ b/src/lib/ActionOutputModal.svelte @@ -0,0 +1,543 @@ + + + + + + + + + + diff --git a/src/lib/BranchCard.svelte b/src/lib/BranchCard.svelte index fe3b60a..c5bafc1 100644 --- a/src/lib/BranchCard.svelte +++ b/src/lib/BranchCard.svelte @@ -26,6 +26,13 @@ GitPullRequest, RefreshCw, X, + Wand, + CheckCircle, + StopCircle, + Zap, + FlaskConical, + BrushCleaning, + Hammer, } from 'lucide-svelte'; import type { Branch, @@ -34,8 +41,12 @@ BranchNote, OpenerApp, PullRequestInfo, + ProjectAction, + ActionType, } from './services/branch'; import * as branchService from './services/branch'; + import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + import ActionOutputModal from './ActionOutputModal.svelte'; import SessionViewerModal from './SessionViewerModal.svelte'; import NoteViewerModal from './NoteViewerModal.svelte'; import NewSessionModal from './NewSessionModal.svelte'; @@ -178,6 +189,8 @@ // Dropdown state let showNewDropdown = $state(false); let showMoreMenu = $state(false); + let expandedSubmenu = $state(null); + let submenuCloseTimeout = $state | null>(null); // Open in... button state let openerApps = $state([]); @@ -203,9 +216,135 @@ let syncingFromPr = $state(false); let showPullConfirm = $state(false); + // Actions state + let projectActions = $state([]); + + // Group actions by type + let groupedActions = $derived.by(() => { + const groups: Record = { + prerun: [], + run: [], + build: [], + format: [], + check: [], + test: [], + cleanUp: [], + }; + for (const action of projectActions) { + groups[action.actionType].push(action); + } + return groups; + }); + + // Helper function to get icon for action type + function getActionIcon(actionType: ActionType) { + switch (actionType) { + case 'prerun': + return Zap; + case 'run': + return Play; + case 'build': + return Hammer; + case 'format': + return Wand; + case 'check': + return CheckCircle; + case 'test': + return FlaskConical; + case 'cleanUp': + return BrushCleaning; + } + } + + // Helper function to get label for action type + function getActionTypeLabel(actionType: ActionType): string { + switch (actionType) { + case 'prerun': + return 'Prerun'; + case 'run': + return 'Run'; + case 'build': + return 'Build'; + case 'format': + return 'Format'; + case 'check': + return 'Check'; + case 'test': + return 'Test'; + case 'cleanUp': + return 'Clean Up'; + } + } + + // Get the primary run action (first run action) + let primaryRunAction = $derived.by(() => { + return groupedActions.run[0] ?? null; + }); + + // Get remaining run actions (excluding the primary one) + let remainingRunActions = $derived.by(() => { + return groupedActions.run.slice(1); + }); + + // Track the primary action's execution status + let primaryActionExecution = $derived.by(() => { + if (!primaryRunAction) return null; + return runningActions.find((a) => a.actionId === primaryRunAction.id) ?? null; + }); + + // Filter running actions to exclude the primary action + let secondaryRunningActions = $derived.by(() => { + if (!primaryRunAction) return runningActions; + return runningActions.filter((a) => a.actionId !== primaryRunAction.id); + }); + + // Submenu handlers with delay to prevent flashing + function handleSubmenuEnter(type: ActionType) { + if (submenuCloseTimeout) { + clearTimeout(submenuCloseTimeout); + submenuCloseTimeout = null; + } + expandedSubmenu = type; + } + + function handleSubmenuLeave() { + submenuCloseTimeout = setTimeout(() => { + expandedSubmenu = null; + submenuCloseTimeout = null; + }, 100); + } + + type RunningAction = { + executionId: string; + actionId: string; + actionName: string; + status: 'running' | 'completed' | 'failed' | 'stopped'; + exitCode?: number | null; + startedAt?: number; + completedAt?: number | null; + fading?: boolean; + }; + let runningActions = $state([]); + + // Action output modal state + let showActionOutput = $state(false); + let viewingActionExecution = $state<{ + executionId: string; + actionId: string; + actionName: string; + status?: 'running' | 'completed' | 'failed' | 'stopped'; + exitCode?: number | null; + startedAt?: number; + completedAt?: number | null; + } | null>(null); + + // Event listeners + let unlistenActionStatus: UnlistenFn | null = null; + let unlistenActionAutoCommit: UnlistenFn | null = null; + // Load commits and running session on mount - onMount(async () => { - await loadData(); + onMount(() => { + loadData(); // Load available openers (shared across all cards via cache) if (!openersLoaded) { branchService.getAvailableOpeners().then((apps) => { @@ -213,6 +352,106 @@ openersLoaded = true; }); } + + // Load project actions + if (branch.projectId) { + branchService + .listProjectActions(branch.projectId) + .then((actions) => { + projectActions = actions; + }) + .catch((e) => { + console.error('Failed to load project actions:', e); + }); + } + + // Listen for action status events + listen('action_status', (event: any) => { + const payload = event.payload as { + executionId: string; + branchId: string; + actionId: string; + actionName: string; + status: string; + exitCode: number | null; + startedAt: number; + completedAt: number | null; + }; + + if (payload.branchId === branch.id) { + const existingIndex = runningActions.findIndex( + (a) => a.executionId === payload.executionId + ); + + if (payload.status === 'running') { + if (existingIndex === -1) { + runningActions.push({ + executionId: payload.executionId, + actionId: payload.actionId, + actionName: payload.actionName, + status: 'running', + startedAt: payload.startedAt, + }); + } + } else { + // Action completed/failed/stopped - update status + if (existingIndex !== -1) { + runningActions[existingIndex].status = payload.status as any; + runningActions[existingIndex].exitCode = payload.exitCode; + runningActions[existingIndex].completedAt = payload.completedAt; + // Auto-remove successful completions (with fade for secondary, instant for primary) + if (payload.status === 'completed') { + const action = runningActions[existingIndex]; + const isPrimaryAction = primaryRunAction && action.actionId === primaryRunAction.id; + + setTimeout( + () => { + const foundAction = runningActions.find( + (a) => a.executionId === payload.executionId + ); + if (foundAction && !isPrimaryAction) { + // Secondary actions fade out + foundAction.fading = true; + } + // Remove after animation completes (or immediately for primary) + setTimeout( + () => { + runningActions = runningActions.filter( + (a) => a.executionId !== payload.executionId + ); + }, + isPrimaryAction ? 0 : 300 + ); // Match CSS transition duration for secondary + }, + isPrimaryAction ? 1000 : 2000 + ); // Shorter display time for primary action + } + } + } + } + }).then((unlisten) => { + unlistenActionStatus = unlisten; + }); + + // Listen for auto-commit events + listen('action_auto_commit', async (event: any) => { + const payload = event.payload as { + branchId: string; + actionName: string; + }; + + if (payload.branchId === branch.id) { + // Refresh commits to show the new commit + await loadData(); + } + }).then((unlisten) => { + unlistenActionAutoCommit = unlisten; + }); + + return () => { + if (unlistenActionStatus) unlistenActionStatus(); + if (unlistenActionAutoCommit) unlistenActionAutoCommit(); + }; }); // Reload when refreshKey changes @@ -225,16 +464,21 @@ async function loadData() { loading = true; try { - const [commitsResult, sessionResult, notesResult, generatingNoteResult] = await Promise.all([ - branchService.getBranchCommits(branch.id), - branchService.getRunningSession(branch.id), - branchService.listBranchNotes(branch.id), - branchService.getGeneratingNote(branch.id), - ]); + const [commitsResult, sessionResult, notesResult, generatingNoteResult, actionsResult] = + await Promise.all([ + branchService.getBranchCommits(branch.id), + branchService.getRunningSession(branch.id), + branchService.listBranchNotes(branch.id), + branchService.getGeneratingNote(branch.id), + branch.projectId + ? branchService.listProjectActions(branch.projectId) + : Promise.resolve([]), + ]); commits = commitsResult; runningSession = sessionResult; notes = notesResult; generatingNote = generatingNoteResult; + projectActions = actionsResult; // Check if running session is actually alive if (sessionResult?.aiSessionId) { @@ -521,6 +765,32 @@ } } + // Handle running an action + async function handleRunAction(action: ProjectAction) { + showMoreMenu = false; + try { + await branchService.runBranchAction(branch.id, action.id); + // The running action will be added via the event listener + // Don't auto-show output modal - user can click to view + } catch (e) { + console.error('Failed to run action:', e); + } + } + + // Handle showing action output + function handleShowActionOutput(execution: RunningAction) { + viewingActionExecution = { + executionId: execution.executionId, + actionId: execution.actionId, + actionName: execution.actionName, + status: execution.status, + exitCode: execution.exitCode ?? null, + startedAt: execution.startedAt, + completedAt: execution.completedAt, + }; + showActionOutput = true; + } + // Handle discarding a stuck session async function handleDiscardSession() { if (!runningSession) return; @@ -589,23 +859,56 @@
-
- - {#if showMoreMenu} -
- - -
- {/if} -
+ + {#each secondaryRunningActions as execution (execution.executionId)} +
+ +
+ {/each} + + {#if primaryRunAction} +
+ +
+ {/if} {#if openerApps.length > 0}
@@ -624,6 +927,81 @@ {/if}
{/if} +
+ + {#if showMoreMenu} +
+ + + + + {#if projectActions.length > 0} + + {#each ['run', 'build', 'format', 'check', 'test', 'cleanUp', 'prerun'] as type} + {@const actions = + type === 'run' ? remainingRunActions : groupedActions[type as ActionType]} + {#if actions.length > 0} + {#if actions.length >= 3} + + + {:else} + + {#each actions as action (action.id)} + {@const Icon = getActionIcon(type as ActionType)} + + {/each} + {/if} + {/if} + {/each} + {/if} + + + + +
+ {/if} +
@@ -1080,6 +1458,24 @@ /> {/if} + +{#if showActionOutput && viewingActionExecution} + { + showActionOutput = false; + viewingActionExecution = null; + }} + /> +{/if} + {#if showNoteViewer && viewingNote} + + + + + + + diff --git a/src/lib/features/agent/AgentPanel.svelte b/src/lib/features/agent/AgentPanel.svelte index 56f06fc..b4fa9ba 100644 --- a/src/lib/features/agent/AgentPanel.svelte +++ b/src/lib/features/agent/AgentPanel.svelte @@ -22,8 +22,6 @@ Circle, CheckCircle2, MessageSquare, - Clipboard, - Image as ImageIcon, } from 'lucide-svelte'; import { sendAgentPromptStreaming, @@ -46,7 +44,7 @@ } from '../../stores/agent.svelte'; import { commentsState } from '../../stores/comments.svelte'; import { preferences } from '../../stores/preferences.svelte'; - import type { DiffSpec, FileDiffSummary, ImageAttachment } from '../../types'; + import type { DiffSpec, FileDiffSummary } from '../../types'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -97,74 +95,6 @@ // Count total comments for display let totalCommentsCount = $derived(commentsState.comments.length); - // Image attachments for the current prompt - let attachedImages = $state([]); - let imageError = $state(null); - const MAX_IMAGES = 5; - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp']; - - /** - * Validate an image file. - */ - function validateImage(file: File): string | null { - if (!ALLOWED_MIME_TYPES.includes(file.type)) { - return `Unsupported image format. Allowed: PNG, JPEG, GIF, WebP`; - } - if (file.size > MAX_FILE_SIZE) { - return `Image too large (max 10MB)`; - } - return null; - } - - /** - * Convert a File object to an ImageAttachment. - */ - async function fileToImageAttachment(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - const base64 = result.split(',')[1]; - resolve({ - data: base64, - mime_type: file.type, - }); - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - } - - /** - * Read an image from the clipboard using ClipboardEvent API. - */ - async function readClipboardImage(): Promise { - try { - const clipboardItems = await navigator.clipboard.read(); - for (const item of clipboardItems) { - for (const type of item.types) { - if (type.startsWith('image/')) { - const blob = await item.getType(type); - const file = new File([blob], 'clipboard-image', { type }); - - const validationError = validateImage(file); - if (validationError) { - imageError = validationError; - return null; - } - - return await fileToImageAttachment(file); - } - } - } - return null; - } catch (error) { - console.error('Failed to read image from clipboard:', error); - return null; - } - } - /** Type guard to validate provider ID */ function isValidProvider(id: string): id is AcpProvider { return id === 'goose' || id === 'claude' || id === 'codex'; @@ -439,17 +369,12 @@ repoPath: repoPath ?? undefined, sessionId: tabState.sessionId ?? undefined, provider: tabState.provider, - images: attachedImages.length > 0 ? attachedImages : undefined, }); // Write final response to the captured tab state tabState.response = result.response; tabState.sessionId = result.sessionId; - // Clear attached images after successful send - attachedImages = []; - imageError = null; - // Clear the live session now that we have the final response liveSessionStore.clear(result.sessionId); } catch (e) { @@ -469,116 +394,6 @@ } } - /** - * Handle clipboard paste button click. - */ - async function handlePasteFromClipboard() { - if (attachedImages.length >= MAX_IMAGES) { - imageError = `Maximum ${MAX_IMAGES} images allowed`; - return; - } - - imageError = null; - const image = await readClipboardImage(); - - if (!image) { - imageError = 'No image found in clipboard'; - return; - } - - attachedImages = [...attachedImages, image]; - } - - /** - * Handle file drop for images. - */ - async function handleImageDrop(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - - if (attachedImages.length >= MAX_IMAGES) { - imageError = `Maximum ${MAX_IMAGES} images allowed`; - return; - } - - const files = event.dataTransfer?.files; - if (!files || files.length === 0) return; - - imageError = null; - - for (let i = 0; i < files.length && attachedImages.length < MAX_IMAGES; i++) { - const file = files[i]; - - if (!file.type.startsWith('image/')) { - continue; - } - - const validationError = validateImage(file); - if (validationError) { - imageError = validationError; - continue; - } - - try { - const imageAttachment = await fileToImageAttachment(file); - attachedImages = [...attachedImages, imageAttachment]; - } catch (error) { - imageError = error instanceof Error ? error.message : 'Failed to load image'; - } - } - } - - /** - * Handle file input change for images. - */ - async function handleFileSelect(event: Event) { - const input = event.target as HTMLInputElement; - const files = input.files; - if (!files || files.length === 0) return; - - if (attachedImages.length >= MAX_IMAGES) { - imageError = `Maximum ${MAX_IMAGES} images allowed`; - return; - } - - imageError = null; - - for (let i = 0; i < files.length && attachedImages.length < MAX_IMAGES; i++) { - const file = files[i]; - const validationError = validateImage(file); - - if (validationError) { - imageError = validationError; - continue; - } - - try { - const imageAttachment = await fileToImageAttachment(file); - attachedImages = [...attachedImages, imageAttachment]; - } catch (error) { - imageError = error instanceof Error ? error.message : 'Failed to load image'; - } - } - - input.value = ''; - } - - /** - * Remove an attached image. - */ - function removeImage(index: number) { - attachedImages = attachedImages.filter((_, i) => i !== index); - imageError = null; - } - - /** - * Handle drag over event. - */ - function handleDragOver(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - } - /** * Save the current response as an artifact. * Keeps the session alive so user can continue the conversation. @@ -897,35 +712,7 @@ {/if} - {#if imageError} -
{imageError}
- {/if} - - {#if attachedImages.length > 0} -
- {#each attachedImages as image, i (i)} -
- Attachment {i + 1} - -
- {/each} -
- {/if} - -
+
- - {#if agentGlobalState.availableProviders.length > 0}