From d406f2935d8ee97e55c349d84027cf5586a639a3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Feb 2026 17:55:22 +1100 Subject: [PATCH] feat: add project actions system with AI-powered detection and execution Implement a comprehensive project actions system that allows users to define, detect, and execute common project tasks. Key features include: - AI-powered action detection that analyzes project structure and suggests relevant actions - Action execution UI with real-time output, ANSI support, and progress bars - Project settings modal for managing actions and project configuration - Support for multiple action types: Test, Lint, Format, Build, CleanUp, PreRun, Custom - Action runner with proper environment handling and shell integration - Database schema and CRUD operations for persisting project actions - Smart categorization and UI organization of actions in branch cards The system integrates with the existing branch workflow, automatically detecting and suggesting actions when creating new projects, and providing quick access to common tasks through the branch card UI. Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 25 + package.json | 1 + src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/actions/detector.rs | 260 ++++++++ src-tauri/src/actions/mod.rs | 5 + src-tauri/src/actions/runner.rs | 414 ++++++++++++ src-tauri/src/ai/client.rs | 73 +-- src-tauri/src/ai/mod.rs | 4 +- src-tauri/src/ai/session.rs | 44 +- src-tauri/src/lib.rs | 400 ++++++++---- src-tauri/src/store.rs | 289 ++++++++- src/App.svelte | 125 ++-- src/lib/ActionOutputModal.svelte | 543 ++++++++++++++++ src/lib/BranchCard.svelte | 596 ++++++++++++++++- src/lib/BranchHome.svelte | 166 +++-- src/lib/NewBranchModal.svelte | 40 +- src/lib/NewProjectModal.svelte | 36 +- src/lib/NewSessionModal.svelte | 256 +------- src/lib/ProjectSettingsModal.svelte | 787 +++++++++++++++++++++++ src/lib/features/agent/AgentPanel.svelte | 348 +--------- src/lib/services/ai.ts | 11 +- src/lib/services/branch.ts | 164 ++++- src/lib/stores/tabState.svelte.ts | 88 +-- src/lib/types.ts | 8 - 25 files changed, 3602 insertions(+), 1085 deletions(-) create mode 100644 src-tauri/src/actions/detector.rs create mode 100644 src-tauri/src/actions/mod.rs create mode 100644 src-tauri/src/actions/runner.rs create mode 100644 src/lib/ActionOutputModal.svelte create mode 100644 src/lib/ProjectSettingsModal.svelte 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}