diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 5639fc4f..0a5f2ddd 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -112,6 +112,8 @@ fn main() { vibe_kanban::models::task_attempt::FileDiff::decl(), vibe_kanban::models::task_attempt::WorktreeDiff::decl(), vibe_kanban::models::task_attempt::BranchStatus::decl(), + vibe_kanban::models::task_attempt::ExecutionState::decl(), + vibe_kanban::models::task_attempt::TaskAttemptState::decl(), vibe_kanban::models::execution_process::ExecutionProcess::decl(), vibe_kanban::models::execution_process::ExecutionProcessSummary::decl(), vibe_kanban::models::execution_process::ExecutionProcessStatus::decl(), @@ -121,6 +123,10 @@ fn main() { vibe_kanban::models::executor_session::ExecutorSession::decl(), vibe_kanban::models::executor_session::CreateExecutorSession::decl(), vibe_kanban::models::executor_session::UpdateExecutorSession::decl(), + vibe_kanban::executor::NormalizedConversation::decl(), + vibe_kanban::executor::NormalizedEntry::decl(), + vibe_kanban::executor::NormalizedEntryType::decl(), + vibe_kanban::executor::ActionType::decl(), ]; // 4. Friendly banner diff --git a/backend/src/executor.rs b/backend/src/executor.rs index bffa7bda..24be66d1 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -10,6 +10,57 @@ use crate::executors::{ AmpExecutor, ClaudeExecutor, EchoExecutor, GeminiExecutor, OpencodeExecutor, }; +/// Normalized conversation representation for different executor formats +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct NormalizedConversation { + pub entries: Vec, + pub session_id: Option, + pub executor_type: String, + pub prompt: Option, + pub summary: Option, +} + +/// Individual entry in a normalized conversation +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct NormalizedEntry { + pub timestamp: Option, + pub entry_type: NormalizedEntryType, + pub content: String, + #[ts(skip)] + pub metadata: Option, +} + +/// Types of entries in a normalized conversation +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export)] +pub enum NormalizedEntryType { + UserMessage, + AssistantMessage, + ToolUse { + tool_name: String, + action_type: ActionType, + }, + SystemMessage, + Thinking, +} + +/// Types of tool actions that can be performed +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "action", rename_all = "snake_case")] +#[ts(export)] +pub enum ActionType { + FileRead { path: String }, + FileWrite { path: String }, + CommandRun { command: String }, + Search { query: String }, + WebFetch { url: String }, + TaskCreate { description: String }, + Other { description: String }, +} + /// Context information for spawn failures to provide comprehensive error details #[derive(Debug, Clone)] pub struct SpawnContext { @@ -167,6 +218,18 @@ pub trait Executor: Send + Sync { worktree_path: &str, ) -> Result; + /// Normalize executor logs into a standard format + fn normalize_logs(&self, _logs: &str) -> Result { + // Default implementation returns empty conversation + Ok(NormalizedConversation { + entries: vec![], + session_id: None, + executor_type: "unknown".to_string(), + prompt: None, + summary: None, + }) + } + /// Execute the command and stream output to database in real-time async fn execute_streaming( &self, diff --git a/backend/src/executor_tests.rs b/backend/src/executor_tests.rs new file mode 100644 index 00000000..69ac1808 --- /dev/null +++ b/backend/src/executor_tests.rs @@ -0,0 +1,112 @@ +#[cfg(test)] +mod tests { + use crate::{ + executor::{Executor, NormalizedEntryType}, + executors::{AmpExecutor, ClaudeExecutor}, + }; + + #[test] + fn test_amp_log_normalization() { + let amp_executor = AmpExecutor; + let amp_logs = r#"{"type":"initial","threadID":"T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a"} +{"type":"messages","messages":[[0,{"role":"user","content":[{"type":"text","text":"Task title: Create and start should open task\nTask description: When I press 'create & start' on task creation dialog it should then open the task in the sidebar"}],"meta":{"sentAt":1751544747623}}]],"toolResults":[]} +{"type":"messages","messages":[[1,{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to implement a feature where pressing \"create & start\" on the task creation dialog should open the task in the sidebar."},{"type":"text","text":"I'll help you implement the \"create & start\" functionality. Let me explore the codebase to understand the current task creation and sidebar structure."},{"type":"tool_use","id":"toolu_01FQqskzGAhZaZu8H6qSs5pV","name":"todo_write","input":{"todos":[{"id":"1","content":"Explore task creation dialog component","status":"todo","priority":"high"}]}}],"state":{"type":"complete","stopReason":"tool_use"}}]],"toolResults":[]}"#; + + let result = amp_executor.normalize_logs(amp_logs).unwrap(); + + assert_eq!(result.executor_type, "amp"); + assert_eq!( + result.session_id, + Some("T-f8f7fec0-b330-47ab-b63a-b72c42f1ef6a".to_string()) + ); + assert!(!result.entries.is_empty()); + + // Check that we have user message, assistant message, thinking, and tool use entries + let user_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::UserMessage)) + .collect(); + assert!(!user_messages.is_empty()); + + let assistant_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) + .collect(); + assert!(!assistant_messages.is_empty()); + + let thinking_entries: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::Thinking)) + .collect(); + assert!(!thinking_entries.is_empty()); + + let tool_uses: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) + .collect(); + assert!(!tool_uses.is_empty()); + + // Check that tool use content is concise (not the old verbose format) + let todo_tool_use = tool_uses.iter().find(|e| match &e.entry_type { + NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "todo_write", + _ => false, + }); + assert!(todo_tool_use.is_some()); + let todo_tool_use = todo_tool_use.unwrap(); + // Should be concise, not "Tool: todo_write with input: ..." + assert_eq!(todo_tool_use.content, "Managing TODO list"); + } + + #[test] + fn test_claude_log_normalization() { + let claude_executor = ClaudeExecutor; + let claude_logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp/mission-control-worktree-8ff34214-7bb4-4a5a-9f47-bfdf79e20368","session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"bypassPermissions","apiKeySource":"none"} +{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll help you list your projects using vibe-kanban. Let me first explore the codebase to understand how vibe-kanban works and find your projects."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"} +{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01Br3TvXdmW6RPGpB5NihTHh","name":"Task","input":{"description":"Find vibe-kanban projects","prompt":"I need to find and list projects using vibe-kanban."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}"#; + + let result = claude_executor.normalize_logs(claude_logs).unwrap(); + + assert_eq!(result.executor_type, "claude"); + assert_eq!( + result.session_id, + Some("499dcce4-04aa-4a3e-9e0c-ea0228fa87c9".to_string()) + ); + assert!(!result.entries.is_empty()); + + // Check that we have system, assistant message, and tool use entries + let system_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::SystemMessage)) + .collect(); + assert!(!system_messages.is_empty()); + + let assistant_messages: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::AssistantMessage)) + .collect(); + assert!(!assistant_messages.is_empty()); + + let tool_uses: Vec<_> = result + .entries + .iter() + .filter(|e| matches!(e.entry_type, NormalizedEntryType::ToolUse { .. })) + .collect(); + assert!(!tool_uses.is_empty()); + + // Check that tool use content is concise (not the old verbose format) + let task_tool_use = tool_uses.iter().find(|e| match &e.entry_type { + NormalizedEntryType::ToolUse { tool_name, .. } => tool_name == "Task", + _ => false, + }); + assert!(task_tool_use.is_some()); + let task_tool_use = task_tool_use.unwrap(); + // Should be the task description, not "Tool: Task with input: ..." + assert_eq!(task_tool_use.content, "Find vibe-kanban projects"); + } +} diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index 3b327107..8c9b1c60 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -3,7 +3,10 @@ use command_group::{AsyncCommandGroup, AsyncGroupChild}; use uuid::Uuid; use crate::{ - executor::{Executor, ExecutorError}, + executor::{ + ActionType, Executor, ExecutorError, NormalizedConversation, NormalizedEntry, + NormalizedEntryType, + }, models::task::Task, utils::shell::get_shell_command, }; @@ -73,6 +76,284 @@ impl Executor for AmpExecutor { Ok(child) } + + fn normalize_logs(&self, logs: &str) -> Result { + use serde_json::Value; + + let mut entries = Vec::new(); + let mut session_id = None; + + for line in logs.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Try to parse as JSON + let json: Value = serde_json::from_str(trimmed) + .map_err(|e| format!("Failed to parse JSON: {}", e))?; + + // Extract session ID (threadID in AMP) + if session_id.is_none() { + if let Some(thread_id) = json.get("threadID").and_then(|v| v.as_str()) { + session_id = Some(thread_id.to_string()); + } + } + + // Process different message types + if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + if msg_type == "messages" { + if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) { + for message_entry in messages { + if let Some(message_data) = + message_entry.as_array().and_then(|arr| arr.get(1)) + { + if let Some(role) = + message_data.get("role").and_then(|r| r.as_str()) + { + if let Some(content) = + message_data.get("content").and_then(|c| c.as_array()) + { + for content_item in content { + if let Some(content_type) = + content_item.get("type").and_then(|t| t.as_str()) + { + match content_type { + "text" => { + if let Some(text) = content_item + .get("text") + .and_then(|t| t.as_str()) + { + let entry_type = match role { + "user" => NormalizedEntryType::UserMessage, + "assistant" => NormalizedEntryType::AssistantMessage, + _ => continue, + }; + entries.push(NormalizedEntry { + timestamp: message_data + .get("meta") + .and_then(|m| m.get("sentAt")) + .and_then(|s| s.as_u64()) + .map(|ts| ts.to_string()), + entry_type, + content: text.to_string(), + metadata: Some( + content_item.clone(), + ), + }); + } + } + "thinking" => { + if let Some(thinking) = content_item + .get("thinking") + .and_then(|t| t.as_str()) + { + entries.push(NormalizedEntry { + timestamp: None, + entry_type: + NormalizedEntryType::Thinking, + content: thinking.to_string(), + metadata: Some( + content_item.clone(), + ), + }); + } + } + "tool_use" => { + if let Some(tool_name) = content_item + .get("name") + .and_then(|n| n.as_str()) + { + let input = content_item + .get("input") + .unwrap_or(&Value::Null); + let action_type = self + .extract_action_type( + tool_name, input, + ); + let content = self + .generate_concise_content( + tool_name, + input, + &action_type, + ); + + entries.push(NormalizedEntry { + timestamp: None, + entry_type: + NormalizedEntryType::ToolUse { + tool_name: tool_name + .to_string(), + action_type, + }, + content, + metadata: Some( + content_item.clone(), + ), + }); + } + } + _ => {} + } + } + } + } + } + } + } + } + } + } + } + + Ok(NormalizedConversation { + entries, + session_id, + executor_type: "amp".to_string(), + prompt: None, + summary: None, + }) + } +} + +impl AmpExecutor { + fn generate_concise_content( + &self, + tool_name: &str, + input: &serde_json::Value, + action_type: &ActionType, + ) -> String { + match action_type { + ActionType::FileRead { path } => path.clone(), + ActionType::FileWrite { path } => path.clone(), + ActionType::CommandRun { command } => command.clone(), + ActionType::Search { query } => query.clone(), + ActionType::WebFetch { url } => url.clone(), + ActionType::TaskCreate { description } => description.clone(), + ActionType::Other { description: _ } => { + // For other tools, try to extract key information or fall back to tool name + match tool_name.to_lowercase().as_str() { + "todo_write" | "todo_read" => "Managing TODO list".to_string(), + "list_directory" | "ls" => { + if let Some(path) = input.get("path").and_then(|p| p.as_str()) { + format!("List directory: {}", path) + } else { + "List directory".to_string() + } + } + "codebase_search_agent" => { + if let Some(query) = input.get("query").and_then(|q| q.as_str()) { + format!("Search: {}", query) + } else { + "Codebase search".to_string() + } + } + "glob" => { + if let Some(pattern) = input.get("filePattern").and_then(|p| p.as_str()) { + format!("File pattern: {}", pattern) + } else { + "File pattern search".to_string() + } + } + _ => tool_name.to_string(), + } + } + } + } + + fn extract_action_type(&self, tool_name: &str, input: &serde_json::Value) -> ActionType { + match tool_name.to_lowercase().as_str() { + "read_file" | "read" => { + if let Some(path) = input.get("path").and_then(|p| p.as_str()) { + ActionType::FileRead { + path: path.to_string(), + } + } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + ActionType::FileRead { + path: file_path.to_string(), + } + } else { + ActionType::Other { + description: "File read operation".to_string(), + } + } + } + "edit_file" | "write" | "create_file" => { + if let Some(path) = input.get("path").and_then(|p| p.as_str()) { + ActionType::FileWrite { + path: path.to_string(), + } + } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + ActionType::FileWrite { + path: file_path.to_string(), + } + } else { + ActionType::Other { + description: "File write operation".to_string(), + } + } + } + "bash" | "run_command" => { + if let Some(cmd) = input.get("cmd").and_then(|c| c.as_str()) { + ActionType::CommandRun { + command: cmd.to_string(), + } + } else if let Some(command) = input.get("command").and_then(|c| c.as_str()) { + ActionType::CommandRun { + command: command.to_string(), + } + } else { + ActionType::Other { + description: "Command execution".to_string(), + } + } + } + "grep" | "search" => { + if let Some(pattern) = input.get("pattern").and_then(|p| p.as_str()) { + ActionType::Search { + query: pattern.to_string(), + } + } else if let Some(query) = input.get("query").and_then(|q| q.as_str()) { + ActionType::Search { + query: query.to_string(), + } + } else { + ActionType::Other { + description: "Search operation".to_string(), + } + } + } + "web_fetch" | "webfetch" => { + if let Some(url) = input.get("url").and_then(|u| u.as_str()) { + ActionType::WebFetch { + url: url.to_string(), + } + } else { + ActionType::Other { + description: "Web fetch operation".to_string(), + } + } + } + "task" => { + if let Some(description) = input.get("description").and_then(|d| d.as_str()) { + ActionType::TaskCreate { + description: description.to_string(), + } + } else if let Some(prompt) = input.get("prompt").and_then(|p| p.as_str()) { + ActionType::TaskCreate { + description: prompt.to_string(), + } + } else { + ActionType::Other { + description: "Task creation".to_string(), + } + } + } + _ => ActionType::Other { + description: format!("Tool: {}", tool_name), + }, + } + } } #[async_trait] @@ -123,4 +404,10 @@ impl Executor for AmpFollowupExecutor { Ok(child) } + + fn normalize_logs(&self, logs: &str) -> Result { + // Reuse the same logic as the main AmpExecutor + let main_executor = AmpExecutor; + main_executor.normalize_logs(logs) + } } diff --git a/backend/src/executors/claude.rs b/backend/src/executors/claude.rs index 4450f08a..6b482af6 100644 --- a/backend/src/executors/claude.rs +++ b/backend/src/executors/claude.rs @@ -4,7 +4,10 @@ use tokio::process::Command; use uuid::Uuid; use crate::{ - executor::{Executor, ExecutorError}, + executor::{ + ActionType, Executor, ExecutorError, NormalizedConversation, NormalizedEntry, + NormalizedEntryType, + }, models::task::Task, utils::shell::get_shell_command, }; @@ -68,6 +71,277 @@ impl Executor for ClaudeExecutor { Ok(child) } + + fn normalize_logs(&self, logs: &str) -> Result { + use serde_json::Value; + + let mut entries = Vec::new(); + let mut session_id = None; + + for line in logs.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Try to parse as JSON + let json: Value = serde_json::from_str(trimmed) + .map_err(|e| format!("Failed to parse JSON: {}", e))?; + + // Extract session ID + if session_id.is_none() { + if let Some(sess_id) = json.get("session_id").and_then(|v| v.as_str()) { + session_id = Some(sess_id.to_string()); + } + } + + // Process different message types + if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + match msg_type { + "assistant" => { + if let Some(message) = json.get("message") { + if let Some(content) = message.get("content").and_then(|c| c.as_array()) + { + for content_item in content { + if let Some(content_type) = + content_item.get("type").and_then(|t| t.as_str()) + { + match content_type { + "text" => { + if let Some(text) = content_item + .get("text") + .and_then(|t| t.as_str()) + { + entries.push(NormalizedEntry { + timestamp: None, + entry_type: + NormalizedEntryType::AssistantMessage, + content: text.to_string(), + metadata: Some(content_item.clone()), + }); + } + } + "tool_use" => { + if let Some(tool_name) = content_item + .get("name") + .and_then(|n| n.as_str()) + { + let input = content_item + .get("input") + .unwrap_or(&Value::Null); + let action_type = + self.extract_action_type(tool_name, input); + let content = self.generate_concise_content( + tool_name, + input, + &action_type, + ); + + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::ToolUse { + tool_name: tool_name.to_string(), + action_type, + }, + content, + metadata: Some(content_item.clone()), + }); + } + } + _ => {} + } + } + } + } + } + } + "user" => { + if let Some(message) = json.get("message") { + if let Some(content) = message.get("content").and_then(|c| c.as_array()) + { + for content_item in content { + if let Some(content_type) = + content_item.get("type").and_then(|t| t.as_str()) + { + if content_type == "text" { + if let Some(text) = + content_item.get("text").and_then(|t| t.as_str()) + { + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::UserMessage, + content: text.to_string(), + metadata: Some(content_item.clone()), + }); + } + } + } + } + } + } + } + "system" => { + if let Some(subtype) = json.get("subtype").and_then(|s| s.as_str()) { + if subtype == "init" { + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!( + "System initialized with model: {}", + json.get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + ), + metadata: Some(json.clone()), + }); + } + } + } + _ => {} + } + } + } + + Ok(NormalizedConversation { + entries, + session_id, + executor_type: "claude".to_string(), + prompt: None, + summary: None, + }) + } +} + +impl ClaudeExecutor { + fn generate_concise_content( + &self, + tool_name: &str, + input: &serde_json::Value, + action_type: &ActionType, + ) -> String { + match action_type { + ActionType::FileRead { path } => path.clone(), + ActionType::FileWrite { path } => path.clone(), + ActionType::CommandRun { command } => command.clone(), + ActionType::Search { query } => query.clone(), + ActionType::WebFetch { url } => url.clone(), + ActionType::TaskCreate { description } => description.clone(), + ActionType::Other { description: _ } => { + // For other tools, try to extract key information or fall back to tool name + match tool_name.to_lowercase().as_str() { + "todoread" | "todowrite" => "Managing TODO list".to_string(), + "ls" => { + if let Some(path) = input.get("path").and_then(|p| p.as_str()) { + format!("List directory: {}", path) + } else { + "List directory".to_string() + } + } + "codebase_search_agent" => { + if let Some(query) = input.get("query").and_then(|q| q.as_str()) { + format!("Search: {}", query) + } else { + "Codebase search".to_string() + } + } + _ => tool_name.to_string(), + } + } + } + } + + fn extract_action_type(&self, tool_name: &str, input: &serde_json::Value) -> ActionType { + match tool_name.to_lowercase().as_str() { + "read" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + ActionType::FileRead { + path: file_path.to_string(), + } + } else { + ActionType::Other { + description: "File read operation".to_string(), + } + } + } + "edit" | "write" | "multiedit" => { + if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { + ActionType::FileWrite { + path: file_path.to_string(), + } + } else if let Some(path) = input.get("path").and_then(|p| p.as_str()) { + ActionType::FileWrite { + path: path.to_string(), + } + } else { + ActionType::Other { + description: "File write operation".to_string(), + } + } + } + "bash" => { + if let Some(command) = input.get("command").and_then(|c| c.as_str()) { + ActionType::CommandRun { + command: command.to_string(), + } + } else { + ActionType::Other { + description: "Command execution".to_string(), + } + } + } + "grep" => { + if let Some(pattern) = input.get("pattern").and_then(|p| p.as_str()) { + ActionType::Search { + query: pattern.to_string(), + } + } else { + ActionType::Other { + description: "Search operation".to_string(), + } + } + } + "glob" => { + if let Some(file_pattern) = input.get("filePattern").and_then(|p| p.as_str()) { + ActionType::Search { + query: file_pattern.to_string(), + } + } else { + ActionType::Other { + description: "File pattern search".to_string(), + } + } + } + "webfetch" => { + if let Some(url) = input.get("url").and_then(|u| u.as_str()) { + ActionType::WebFetch { + url: url.to_string(), + } + } else { + ActionType::Other { + description: "Web fetch operation".to_string(), + } + } + } + "task" => { + if let Some(description) = input.get("description").and_then(|d| d.as_str()) { + ActionType::TaskCreate { + description: description.to_string(), + } + } else if let Some(prompt) = input.get("prompt").and_then(|p| p.as_str()) { + ActionType::TaskCreate { + description: prompt.to_string(), + } + } else { + ActionType::Other { + description: "Task creation".to_string(), + } + } + } + _ => ActionType::Other { + description: format!("Tool: {}", tool_name), + }, + } + } } #[async_trait] @@ -109,4 +383,10 @@ impl Executor for ClaudeFollowupExecutor { Ok(child) } + + fn normalize_logs(&self, logs: &str) -> Result { + // Reuse the same logic as the main ClaudeExecutor + let main_executor = ClaudeExecutor; + main_executor.normalize_logs(logs) + } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 11730bc1..fb74c107 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -10,6 +10,9 @@ pub mod routes; pub mod services; pub mod utils; +#[cfg(test)] +mod executor_tests; + #[derive(RustEmbed)] #[folder = "../frontend/dist"] pub struct Assets; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 07378852..2a786e1b 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -148,6 +148,29 @@ pub struct BranchStatus { pub base_branch_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum ExecutionState { + NotStarted, + SetupRunning, + SetupComplete, + SetupFailed, + CodingAgentRunning, + CodingAgentComplete, + CodingAgentFailed, + Complete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TaskAttemptState { + pub execution_state: ExecutionState, + pub has_changes: bool, + pub has_setup_script: bool, + pub setup_process_id: Option, + pub coding_agent_process_id: Option, +} + impl TaskAttempt { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( @@ -1241,11 +1264,156 @@ impl TaskAttempt { None, None, )?; + + // Now also get unstaged changes (working directory changes) + let current_tree = worktree_repo.head()?.peel_to_tree()?; + + // Create diff from HEAD to working directory for unstaged changes + let mut unstaged_diff_opts = git2::DiffOptions::new(); + unstaged_diff_opts.context_lines(10); + unstaged_diff_opts.interhunk_lines(0); + unstaged_diff_opts.include_untracked(true); // Include untracked files + + let unstaged_diff = worktree_repo.diff_tree_to_workdir_with_index( + Some(¤t_tree), + Some(&mut unstaged_diff_opts), + )?; + + // Process unstaged changes + unstaged_diff.foreach( + &mut |delta, _progress| { + if let Some(path_str) = delta.new_file().path().and_then(|p| p.to_str()) { + if let Err(e) = Self::process_unstaged_file( + &mut files, + &worktree_repo, + base_oid, + &attempt.worktree_path, + path_str, + &delta, + ) { + eprintln!("Error processing unstaged file {}: {:?}", path_str, e); + } + } + true + }, + None, + None, + None, + )?; } Ok(WorktreeDiff { files }) } + fn process_unstaged_file( + files: &mut Vec, + worktree_repo: &Repository, + base_oid: git2::Oid, + worktree_path: &str, + path_str: &str, + delta: &git2::DiffDelta, + ) -> Result<(), TaskAttemptError> { + let old_file = delta.old_file(); + let new_file = delta.new_file(); + + // Check if we already have a diff for this file from committed changes + if let Some(existing_file) = files.iter_mut().find(|f| f.path == path_str) { + // File already has committed changes, need to create a combined diff + // from the base branch to the current working directory (including unstaged changes) + + // Get the base content (from the fork point) + let base_content = if let Ok(base_commit) = worktree_repo.find_commit(base_oid) { + if let Ok(base_tree) = base_commit.tree() { + match base_tree.get_path(std::path::Path::new(path_str)) { + Ok(entry) => { + if let Ok(blob) = worktree_repo.find_blob(entry.id()) { + String::from_utf8_lossy(blob.content()).to_string() + } else { + String::new() + } + } + Err(_) => String::new(), + } + } else { + String::new() + } + } else { + String::new() + }; + + // Get the working directory content + let working_content = if delta.status() != git2::Delta::Deleted { + let file_path = std::path::Path::new(worktree_path).join(path_str); + std::fs::read_to_string(&file_path).unwrap_or_default() + } else { + String::new() + }; + + // Create a combined diff from base to working directory + if base_content != working_content { + // Use git's patch generation with the content directly + let mut diff_opts = git2::DiffOptions::new(); + diff_opts.context_lines(10); + diff_opts.interhunk_lines(0); + + if let Ok(patch) = git2::Patch::from_buffers( + base_content.as_bytes(), + Some(std::path::Path::new(path_str)), + working_content.as_bytes(), + Some(std::path::Path::new(path_str)), + Some(&mut diff_opts), + ) { + let mut combined_chunks = Vec::new(); + + // Process the patch hunks + for hunk_idx in 0..patch.num_hunks() { + if let Ok((_hunk, hunk_lines)) = patch.hunk(hunk_idx) { + // Process each line in the hunk + for line_idx in 0..hunk_lines { + if let Ok(line) = patch.line_in_hunk(hunk_idx, line_idx) { + let content = + String::from_utf8_lossy(line.content()).to_string(); + + let chunk_type = match line.origin() { + ' ' => DiffChunkType::Equal, + '+' => DiffChunkType::Insert, + '-' => DiffChunkType::Delete, + _ => continue, // Skip other line types + }; + + combined_chunks.push(DiffChunk { + chunk_type, + content, + }); + } + } + } + } + + if !combined_chunks.is_empty() { + existing_file.chunks = combined_chunks; + } + } + } + } else { + // File only has unstaged changes (new file or uncommitted changes only) + match Self::generate_git_diff_chunks(worktree_repo, &old_file, &new_file, path_str) { + Ok(diff_chunks) if !diff_chunks.is_empty() => { + files.push(FileDiff { + path: path_str.to_string(), + chunks: diff_chunks, + }); + } + Err(e) => { + eprintln!("Error generating unstaged diff for {}: {:?}", path_str, e); + } + _ => {} + } + } + + Ok(()) + } + /// Generate diff chunks using Git's native diff algorithm pub fn generate_git_diff_chunks( repo: &Repository, @@ -1785,4 +1953,128 @@ impl TaskAttempt { Ok(()) } + + /// Get the current execution state for a task attempt + pub async fn get_execution_state( + pool: &SqlitePool, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + ) -> Result { + // Get the task attempt with validation + let _attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime", ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, + attempt_id, + task_id, + project_id + ) + .fetch_optional(pool) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Get the project to check if it has a setup script + let project = Project::find_by_id(pool, project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + let has_setup_script = project + .setup_script + .as_ref() + .map(|script| !script.trim().is_empty()) + .unwrap_or(false); + + // Get all execution processes for this attempt, ordered by created_at + let processes = + crate::models::execution_process::ExecutionProcess::find_by_task_attempt_id( + pool, attempt_id, + ) + .await?; + + // Find setup and coding agent processes + let setup_process = processes.iter().find(|p| { + matches!( + p.process_type, + crate::models::execution_process::ExecutionProcessType::SetupScript + ) + }); + + let coding_agent_process = processes.iter().find(|p| { + matches!( + p.process_type, + crate::models::execution_process::ExecutionProcessType::CodingAgent + ) + }); + + // Determine execution state based on processes + let execution_state = if let Some(setup) = setup_process { + match setup.status { + crate::models::execution_process::ExecutionProcessStatus::Running => { + ExecutionState::SetupRunning + } + crate::models::execution_process::ExecutionProcessStatus::Completed => { + if let Some(agent) = coding_agent_process { + match agent.status { + crate::models::execution_process::ExecutionProcessStatus::Running => { + ExecutionState::CodingAgentRunning + } + crate::models::execution_process::ExecutionProcessStatus::Completed => { + ExecutionState::CodingAgentComplete + } + crate::models::execution_process::ExecutionProcessStatus::Failed => { + ExecutionState::CodingAgentFailed + } + crate::models::execution_process::ExecutionProcessStatus::Killed => { + ExecutionState::CodingAgentFailed + } + } + } else { + ExecutionState::SetupComplete + } + } + crate::models::execution_process::ExecutionProcessStatus::Failed => { + ExecutionState::SetupFailed + } + crate::models::execution_process::ExecutionProcessStatus::Killed => { + ExecutionState::SetupFailed + } + } + } else if let Some(agent) = coding_agent_process { + // No setup script, only coding agent + match agent.status { + crate::models::execution_process::ExecutionProcessStatus::Running => { + ExecutionState::CodingAgentRunning + } + crate::models::execution_process::ExecutionProcessStatus::Completed => { + ExecutionState::CodingAgentComplete + } + crate::models::execution_process::ExecutionProcessStatus::Failed => { + ExecutionState::CodingAgentFailed + } + crate::models::execution_process::ExecutionProcessStatus::Killed => { + ExecutionState::CodingAgentFailed + } + } + } else { + // No processes started yet + ExecutionState::NotStarted + }; + + // Check if there are any changes (quick diff check) + let has_changes = match Self::get_diff(pool, attempt_id, task_id, project_id).await { + Ok(diff) => !diff.files.is_empty(), + Err(_) => false, // If diff fails, assume no changes + }; + + Ok(TaskAttemptState { + execution_state, + has_changes, + has_setup_script, + setup_process_id: setup_process.map(|p| p.id.to_string()), + coding_agent_process_id: coding_agent_process.map(|p| p.id.to_string()), + }) + } } diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index e7f45e3e..42d15c53 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -12,18 +12,22 @@ use sqlx::SqlitePool; use tokio::sync::RwLock; use uuid::Uuid; -use crate::models::{ - config::Config, - execution_process::{ExecutionProcess, ExecutionProcessSummary}, - task::Task, - task_attempt::{ - BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt, - TaskAttemptStatus, WorktreeDiff, +use crate::{ + executor::{ExecutorConfig, NormalizedConversation}, + models::{ + config::Config, + execution_process::{ExecutionProcess, ExecutionProcessStatus, ExecutionProcessSummary}, + executor_session::ExecutorSession, + task::Task, + task_attempt::{ + BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt, + TaskAttemptState, TaskAttemptStatus, WorktreeDiff, + }, + task_attempt_activity::{ + CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt, + }, + ApiResponse, }, - task_attempt_activity::{ - CreateTaskAttemptActivity, TaskAttemptActivity, TaskAttemptActivityWithPrompt, - }, - ApiResponse, }; #[derive(Debug, Deserialize, Serialize)] @@ -948,6 +952,175 @@ pub async fn start_dev_server( } } +pub async fn get_task_attempt_execution_state( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + + // Get the execution state + match TaskAttempt::get_execution_state(&pool, attempt_id, task_id, project_id).await { + Ok(state) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(state), + message: None, + })), + Err(e) => { + tracing::error!( + "Failed to get execution state for task attempt {}: {}", + attempt_id, + e + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn get_execution_process_normalized_logs( + Path((project_id, process_id)): Path<(Uuid, Uuid)>, + Extension(pool): Extension, +) -> Result>, StatusCode> { + // Get the execution process and verify it belongs to the correct project + let process = match ExecutionProcess::find_by_id(&pool, process_id).await { + Ok(Some(process)) => process, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch execution process {}: {}", process_id, e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Verify the process belongs to a task attempt in the correct project + let attempt = match TaskAttempt::find_by_id(&pool, process.task_attempt_id).await { + Ok(Some(attempt)) => attempt, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch task attempt: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let _task = match Task::find_by_id(&pool, attempt.task_id).await { + Ok(Some(task)) if task.project_id == project_id => task, + Ok(Some(_)) => return Err(StatusCode::NOT_FOUND), // Wrong project + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch task: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Get logs from the execution process + let logs = match process.stdout { + Some(stdout) => stdout, + None => { + // If the process is still running, return empty logs instead of an error + if process.status == ExecutionProcessStatus::Running { + // Get executor session data for this execution process + let executor_session = + match ExecutorSession::find_by_execution_process_id(&pool, process_id).await { + Ok(session) => session, + Err(e) => { + tracing::error!( + "Failed to fetch executor session for process {}: {}", + process_id, + e + ); + None + } + }; + + return Ok(ResponseJson(ApiResponse { + success: true, + data: Some(NormalizedConversation { + entries: vec![], + session_id: None, + executor_type: process + .executor_type + .clone() + .unwrap_or("unknown".to_string()), + prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()), + summary: executor_session.as_ref().and_then(|s| s.summary.clone()), + }), + message: None, + })); + } + + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("No logs available for this execution process".to_string()), + })); + } + }; + + // Determine executor type and create appropriate executor for normalization + let executor_type = process.executor_type.as_deref().unwrap_or("unknown"); + let executor_config = match executor_type { + "amp" => ExecutorConfig::Amp, + "claude" => ExecutorConfig::Claude, + "echo" => ExecutorConfig::Echo, + "gemini" => ExecutorConfig::Gemini, + "opencode" => ExecutorConfig::Opencode, + _ => { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Unsupported executor type: {}", executor_type)), + })); + } + }; + + let executor = executor_config.create_executor(); + + // Get executor session data for this execution process + let executor_session = + match ExecutorSession::find_by_execution_process_id(&pool, process_id).await { + Ok(session) => session, + Err(e) => { + tracing::error!( + "Failed to fetch executor session for process {}: {}", + process_id, + e + ); + None + } + }; + + // Normalize the logs + match executor.normalize_logs(&logs) { + Ok(mut normalized) => { + // Add prompt and summary from executor session + if let Some(session) = executor_session { + normalized.prompt = session.prompt; + normalized.summary = session.summary; + } + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(normalized), + message: None, + })) + } + Err(e) => { + tracing::error!("Failed to normalize logs for process {}: {}", process_id, e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to normalize logs: {}", e)), + })) + } + } +} + pub fn task_attempts_router() -> Router { use axum::routing::post; @@ -1005,6 +1178,10 @@ pub fn task_attempts_router() -> Router { "/projects/:project_id/execution-processes/:process_id", get(get_execution_process), ) + .route( + "/projects/:project_id/execution-processes/:process_id/normalized-logs", + get(get_execution_process_normalized_logs), + ) .route( "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/follow-up", post(create_followup_attempt), @@ -1013,4 +1190,8 @@ pub fn task_attempts_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/start-dev-server", post(start_dev_server), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id", + get(get_task_attempt_execution_state), + ) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d9153a1..9ce4cfde 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Navbar } from '@/components/layout/navbar'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; -import { TaskAttemptComparePage } from '@/pages/task-attempt-compare'; + import { Settings } from '@/pages/Settings'; import { McpServers } from '@/pages/McpServers'; import { DisclaimerDialog } from '@/components/DisclaimerDialog'; @@ -127,10 +127,7 @@ function AppContent() { path="/projects/:projectId/tasks/:taskId" element={} /> - } - /> + } /> } /> diff --git a/frontend/src/components/tasks/ConversationViewer.tsx b/frontend/src/components/tasks/ConversationViewer.tsx deleted file mode 100644 index 38dca6a9..00000000 --- a/frontend/src/components/tasks/ConversationViewer.tsx +++ /dev/null @@ -1,799 +0,0 @@ -import { useState, useMemo } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; - -import { - Brain, - Wrench as Tool, - ChevronDown, - ChevronUp, - Clock, - Zap, - AlertTriangle, - FileText, -} from 'lucide-react'; - -interface JSONLLine { - type: string; - threadID?: string; - // Amp format - messages?: [ - number, - { - role: 'user' | 'assistant'; - content: Array<{ - type: 'text' | 'thinking' | 'tool_use' | 'tool_result'; - text?: string; - thinking?: string; - id?: string; - name?: string; - input?: any; - toolUseID?: string; - run?: { - status: string; - result?: string; - toAllow?: string[]; - }; - }>; - meta?: { - sentAt: number; - }; - state?: { - type: string; - stopReason?: string; - }; - }, - ][]; - toolResults?: Array<{ - type: 'tool_use' | 'tool_result'; - id?: string; - name?: string; - input?: any; - toolUseID?: string; - run?: { - status: string; - result?: string; - toAllow?: string[]; - }; - }>; - tokenUsage?: { - used: number; - maxAvailable: number; - }; - state?: string; - tool?: string; - command?: string; - // Claude format - message?: { - role: 'user' | 'assistant' | 'system'; - content: - | Array<{ - type: 'text' | 'tool_use' | 'tool_result'; - text?: string; - id?: string; - name?: string; - input?: any; - tool_use_id?: string; - content?: any; - is_error?: boolean; - }> - | string; - }; - messageKey?: number; - isStreaming?: boolean; - // Tool rejection message (string format) - rejectionMessage?: string; - usage?: { - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; - result?: any; - duration_ms?: number; - total_cost_usd?: number; - error?: string; // For parse errors -} - -interface ConversationViewerProps { - jsonlOutput: string; -} - -// Validation functions -const isValidMessage = (data: any): boolean => { - return ( - typeof data.role === 'string' && - Array.isArray(data.content) && - data.content.every( - (item: any) => - typeof item.type === 'string' && - (item.type !== 'text' || typeof item.text === 'string') && - (item.type !== 'thinking' || typeof item.thinking === 'string') && - (item.type !== 'tool_use' || typeof item.name === 'string') && - (item.type !== 'tool_result' || - !item.run || - typeof item.run.status === 'string') - ) - ); -}; - -const isValidClaudeMessage = (data: any): boolean => { - return ( - typeof data.role === 'string' && - (typeof data.content === 'string' || - (Array.isArray(data.content) && - data.content.every( - (item: any) => - typeof item.type === 'string' && - (item.type !== 'text' || typeof item.text === 'string') && - (item.type !== 'tool_use' || typeof item.name === 'string') && - (item.type !== 'tool_result' || typeof item.content !== 'undefined') - ))) - ); -}; - -const isValidTokenUsage = (data: any): boolean => { - return ( - data && - typeof data.used === 'number' && - typeof data.maxAvailable === 'number' - ); -}; - -const isValidClaudeUsage = (data: any): boolean => { - return ( - data && - typeof data.input_tokens === 'number' && - typeof data.output_tokens === 'number' - ); -}; - -const isValidToolRejection = (data: any): boolean => { - return ( - typeof data.tool === 'string' && - typeof data.command === 'string' && - (typeof data.message === 'string' || - typeof data.rejectionMessage === 'string') - ); -}; - -const isValidMessagesLine = (line: any): boolean => { - return ( - Array.isArray(line.messages) && - line.messages.every( - (msg: any) => - Array.isArray(msg) && - msg.length >= 2 && - typeof msg[0] === 'number' && - isValidMessage(msg[1]) - ) - ); -}; - -export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) { - const [expandedMessages, setExpandedMessages] = useState>( - new Set() - ); - const [showTokenUsage, setShowTokenUsage] = useState(false); - - const parsedLines = useMemo(() => { - try { - return jsonlOutput - .split('\n') - .filter((line) => line.trim()) - .map((line, index) => { - try { - const parsed = JSON.parse(line); - return { - ...parsed, - _lineIndex: index, - _rawLine: line, - } as JSONLLine & { _lineIndex: number; _rawLine: string }; - } catch { - return { - type: 'parse-error', - _lineIndex: index, - _rawLine: line, - error: 'Failed to parse JSON', - } as JSONLLine & { - _lineIndex: number; - _rawLine: string; - error: string; - }; - } - }); - } catch { - return []; - } - }, [jsonlOutput]); - - const conversation = useMemo(() => { - const streamingMessageMap = new Map(); - const items: Array<{ - type: 'message' | 'tool-rejection' | 'parse-error' | 'unknown'; - role?: 'user' | 'assistant'; - content?: Array<{ - type: string; - text?: string; - thinking?: string; - id?: string; - name?: string; - input?: any; - toolUseID?: string; - run?: any; - }>; - timestamp?: number; - messageIndex?: number; - lineIndex?: number; - tool?: string; - command?: string; - message?: string; - error?: string; - rawLine?: string; - }> = []; - - const tokenUsages: Array<{ - used: number; - maxAvailable: number; - lineIndex: number; - }> = []; - const states: Array<{ state: string; lineIndex: number }> = []; - - for (const line of parsedLines) { - try { - if (line.type === 'parse-error') { - items.push({ - type: 'parse-error', - error: line.error, - rawLine: line._rawLine, - lineIndex: line._lineIndex, - }); - } else if ( - line.type === 'messages' && - isValidMessagesLine(line) && - line.messages - ) { - // Amp format - for (const [messageIndex, message] of line.messages) { - const messageItem = { - type: 'message' as const, - role: message.role, - content: message.content, - timestamp: message.meta?.sentAt, - messageIndex, - lineIndex: line._lineIndex, - }; - - // Handle Gemini streaming via top-level messageKey and isStreaming - if (line.isStreaming && line.messageKey !== undefined) { - const existingMessage = streamingMessageMap.get(line.messageKey); - if (existingMessage) { - // Append new content to existing message - if ( - existingMessage.content && - existingMessage.content[0] && - messageItem.content && - messageItem.content[0] - ) { - existingMessage.content[0].text = - (existingMessage.content[0].text || '') + - (messageItem.content[0].text || ''); - existingMessage.timestamp = messageItem.timestamp; // Update timestamp - } - } else { - // First segment for this message - streamingMessageMap.set(line.messageKey, messageItem); - } - } else { - items.push(messageItem); - } - } - } else if ( - (line.type === 'user' || - line.type === 'assistant' || - line.type === 'system') && - line.message && - isValidClaudeMessage(line.message) - ) { - // Claude format - const content = - typeof line.message.content === 'string' - ? [{ type: 'text', text: line.message.content }] - : line.message.content; - - items.push({ - type: 'message', - role: - line.message.role === 'system' ? 'assistant' : line.message.role, - content: content, - lineIndex: line._lineIndex, - }); - } else if ( - line.type === 'result' && - line.usage && - isValidClaudeUsage(line.usage) - ) { - // Claude usage info - tokenUsages.push({ - used: line.usage.input_tokens + line.usage.output_tokens, - maxAvailable: - line.usage.input_tokens + line.usage.output_tokens + 100000, // Approximate - lineIndex: line._lineIndex, - }); - } else if ( - line.type === 'token-usage' && - line.tokenUsage && - isValidTokenUsage(line.tokenUsage) - ) { - // Amp format - tokenUsages.push({ - used: line.tokenUsage.used, - maxAvailable: line.tokenUsage.maxAvailable, - lineIndex: line._lineIndex, - }); - } else if (line.type === 'state' && typeof line.state === 'string') { - states.push({ - state: line.state, - lineIndex: line._lineIndex, - }); - } else if ( - line.type === 'tool-rejected' && - isValidToolRejection(line) - ) { - items.push({ - type: 'tool-rejection', - tool: line.tool, - command: line.command, - message: - typeof line.message === 'string' - ? line.message - : line.rejectionMessage || 'Tool rejected', - lineIndex: line._lineIndex, - }); - } else { - // Unknown line type or invalid structure - add as unknown for fallback rendering - items.push({ - type: 'unknown', - rawLine: line._rawLine, - lineIndex: line._lineIndex, - }); - } - } catch (error) { - // If anything goes wrong processing a line, treat it as unknown - items.push({ - type: 'unknown', - rawLine: line._rawLine, - lineIndex: line._lineIndex, - }); - } - } - - const streamingMessages = Array.from(streamingMessageMap.values()); - const finalItems = [...items, ...streamingMessages]; - - // Sort by messageIndex for messages, then by lineIndex for everything else - finalItems.sort((a, b) => { - if (a.type === 'message' && b.type === 'message') { - return (a.messageIndex || 0) - (b.messageIndex || 0); - } - return (a.lineIndex || 0) - (b.lineIndex || 0); - }); - - return { - items: finalItems, - tokenUsages, - states, - }; - }, [parsedLines]); - - const toggleMessage = (messageId: string) => { - const newExpanded = new Set(expandedMessages); - if (newExpanded.has(messageId)) { - newExpanded.delete(messageId); - } else { - newExpanded.add(messageId); - } - setExpandedMessages(newExpanded); - }; - - const formatToolInput = (input: any): string => { - try { - if (input === null || input === undefined) { - return String(input); - } - if (typeof input === 'object') { - // Try to stringify, but handle circular references and complex objects - return JSON.stringify(input); - } - return String(input); - } catch (error) { - // If anything goes wrong, return a safe fallback - return `[Unable to display input: ${String(input).substring(0, 100)}...]`; - } - }; - - const safeRenderString = (value: any): string => { - if (typeof value === 'string') { - return value; - } - if (value === null || value === undefined) { - return String(value); - } - if (typeof value === 'object') { - try { - // Use the same safe JSON.stringify logic as formatToolInput - return '(RAW)' + JSON.stringify(value); - } catch (error) { - return `[Object - serialization failed: ${String(value).substring( - 0, - 50 - )}...]`; - } - } - return String(value); - }; - - const getToolStatusColor = (status: string) => { - switch (status) { - case 'done': - return 'bg-green-500'; - case 'rejected-by-user': - case 'blocked-on-user': - return 'bg-yellow-500'; - case 'error': - return 'bg-red-500'; - default: - return 'bg-blue-500'; - } - }; - - if (parsedLines.length === 0) { - return ( - - -

- No valid JSONL data found -

-
-
- ); - } - - const latestTokenUsage = - conversation.tokenUsages[conversation.tokenUsages.length - 1]; - - return ( -
- {/* Header with token usage */} -
-
- - LLM Conversation -
-
- {latestTokenUsage && ( - - - {latestTokenUsage.used.toLocaleString()} /{' '} - {latestTokenUsage.maxAvailable.toLocaleString()} tokens - - )} - -
-
- - {/* Token usage details */} - {showTokenUsage && conversation.tokenUsages.length > 0 && ( - - - Token Usage Timeline - - -
- {conversation.tokenUsages.map((usage, index) => ( -
- - Step {index + 1} - - - {usage.used.toLocaleString()} /{' '} - {usage.maxAvailable.toLocaleString()} - -
- ))} -
-
-
- )} - - {/* Conversation items (messages and tool rejections) */} -
- {conversation.items.map((item, index) => { - if (item.type === 'parse-error') { - return ( - - -
- - - Parse Error - -
-
-

- Raw JSONL: -

-
-                      {safeRenderString(item.rawLine)}
-                    
-
-
-
- ); - } - - if (item.type === 'unknown') { - let prettyJson = item.rawLine; - try { - prettyJson = JSON.stringify( - JSON.parse(item.rawLine || '{}'), - null, - 2 - ); - } catch { - // Keep as is if can't prettify - } - - return ( - - -
- - - Unknown - -
-
-

JSONL:

-
-                      {safeRenderString(prettyJson)}
-                    
-
-
-
- ); - } - - if (item.type === 'tool-rejection') { - return ( - - -
- - - Tool Rejected - - - {safeRenderString(item.tool)} - -
-
-
-

- Command: -

-
-                        {safeRenderString(item.command)}
-                      
-
-
-

- Message: -

-

- {safeRenderString(item.message)} -

-
-
-
-
- ); - } - - if (item.type === 'message') { - const messageId = `message-${index}`; - const isExpanded = expandedMessages.has(messageId); - const hasThinking = item.content?.some( - (c: any) => c.type === 'thinking' - ); - - return ( - - -
- - {item.role} - - {item.timestamp && ( -
- - {new Date(item.timestamp).toLocaleTimeString()} -
- )} - {hasThinking && ( - - )} -
- -
- {item.content?.map((content: any, contentIndex: number) => { - if (content.type === 'text') { - return ( -
-

- {safeRenderString(content.text)} -

-
- ); - } - - if (content.type === 'thinking' && isExpanded) { - return ( -
-
- - 💭 Thinking - -
-
- {safeRenderString(content.thinking)} -
-
- ); - } - - if (content.type === 'tool_use') { - return ( -
-
- - - {safeRenderString(content.name)} - -
- {content.input && ( -
-                                {formatToolInput(content.input)}
-                              
- )} -
- ); - } - - if (content.type === 'tool_result') { - return ( -
-
-
- {content.run?.status ? ( -
- ) : content.is_error ? ( -
- ) : ( -
- )} -
- - Result - - {content.run?.status && ( - - ({safeRenderString(content.run.status)}) - - )} - {content.is_error && ( - - (Error) - - )} -
- {/* Amp format result */} - {content.run?.result && ( -
-                                {safeRenderString(content.run.result)}
-                              
- )} - {/* Claude format result */} - {content.content && !content.run && ( -
-                                {safeRenderString(content.content)}
-                              
- )} - {content.run?.toAllow && ( -
-

- Commands to allow: -

-
- {content.run.toAllow.map( - (cmd: string, i: number) => ( - - {safeRenderString(cmd)} - - ) - )} -
-
- )} -
- ); - } - - return null; - })} -
- - - ); - } - - return null; - })} -
-
- ); -} diff --git a/frontend/src/components/tasks/ExecutionOutputViewer.tsx b/frontend/src/components/tasks/ExecutionOutputViewer.tsx deleted file mode 100644 index 96cb9a4b..00000000 --- a/frontend/src/components/tasks/ExecutionOutputViewer.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useState, useMemo, useEffect } from 'react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { FileText, MessageSquare } from 'lucide-react'; -import { ConversationViewer } from './ConversationViewer'; -import type { ExecutionProcess, ExecutionProcessStatus } from 'shared/types'; - -interface ExecutionOutputViewerProps { - executionProcess: ExecutionProcess; - executor?: string; -} - -const getExecutionProcessStatusDisplay = ( - status: ExecutionProcessStatus -): { label: string; color: string } => { - switch (status) { - case 'running': - return { label: 'Running', color: 'bg-blue-500' }; - case 'completed': - return { label: 'Completed', color: 'bg-green-500' }; - case 'failed': - return { label: 'Failed', color: 'bg-red-500' }; - case 'killed': - return { label: 'Stopped', color: 'bg-gray-500' }; - default: - return { label: 'Unknown', color: 'bg-gray-400' }; - } -}; - -export function ExecutionOutputViewer({ - executionProcess, - executor, -}: ExecutionOutputViewerProps) { - const [viewMode, setViewMode] = useState<'conversation' | 'raw'>('raw'); - - const isAmpExecutor = executor === 'amp'; - const isClaudeExecutor = executor === 'claude'; - const isGeminiExecutor = executor === 'gemini'; - const hasStdout = !!executionProcess.stdout; - const hasStderr = !!executionProcess.stderr; - - // Check if stdout looks like JSONL (for Amp, Claude, or Gemini executor) - const { isValidJsonl, jsonlFormat } = useMemo(() => { - if ( - (!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) || - !executionProcess.stdout - ) { - return { isValidJsonl: false, jsonlFormat: null }; - } - - try { - const lines = executionProcess.stdout - .split('\n') - .filter((line) => line.trim()); - if (lines.length === 0) return { isValidJsonl: false, jsonlFormat: null }; - - // Try to parse at least the first few lines as JSON - const testLines = lines.slice(0, Math.min(3, lines.length)); - const allValid = testLines.every((line) => { - try { - JSON.parse(line); - return true; - } catch { - return false; - } - }); - - if (!allValid) return { isValidJsonl: false, jsonlFormat: null }; - - // Detect format by checking for Amp vs Claude structure - let hasAmpFormat = false; - let hasClaudeFormat = false; - - for (const line of testLines) { - try { - const parsed = JSON.parse(line); - if (parsed.type === 'messages' || parsed.type === 'token-usage') { - hasAmpFormat = true; - } - if ( - parsed.type === 'user' || - parsed.type === 'assistant' || - parsed.type === 'system' || - parsed.type === 'result' - ) { - hasClaudeFormat = true; - } - } catch { - // Skip invalid lines - } - } - - return { - isValidJsonl: true, - jsonlFormat: hasAmpFormat - ? 'amp' - : hasClaudeFormat - ? 'claude' - : 'unknown', - }; - } catch { - return { isValidJsonl: false, jsonlFormat: null }; - } - }, [ - isAmpExecutor, - isClaudeExecutor, - isGeminiExecutor, - executionProcess.stdout, - ]); - - // Set initial view mode based on JSONL detection - useEffect(() => { - if (isValidJsonl) { - setViewMode('conversation'); - } - }, [isValidJsonl]); - - if (!hasStdout && !hasStderr) { - return ( - - -
- Waiting for output... -
-
-
- ); - } - - const statusDisplay = getExecutionProcessStatusDisplay( - executionProcess.status - ); - - return ( - - -
- {/* Execution process header with status */} -
-
- - {executionProcess.process_type - .replace(/([A-Z])/g, ' $1') - .toLowerCase()} - -
-
- - {statusDisplay.label} - -
- {executor && ( - - {executor} - - )} -
-
- - {/* View mode toggle for executors with valid JSONL */} - {isValidJsonl && hasStdout && ( -
-
- {jsonlFormat && ( - - {jsonlFormat} format - - )} -
-
- - -
-
- )} - - {/* Output content */} - {hasStdout && ( -
- {isValidJsonl && viewMode === 'conversation' ? ( - - ) : ( -
-
-                    {executionProcess.stdout}
-                  
-
- )} -
- )} - - {hasStderr && ( -
-
-                {executionProcess.stderr}
-              
-
- )} -
- - - ); -} diff --git a/frontend/src/components/tasks/NormalizedConversationViewer.tsx b/frontend/src/components/tasks/NormalizedConversationViewer.tsx new file mode 100644 index 00000000..0712cc11 --- /dev/null +++ b/frontend/src/components/tasks/NormalizedConversationViewer.tsx @@ -0,0 +1,221 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + User, + Bot, + Eye, + Edit, + Terminal, + Search, + Globe, + Plus, + Settings, + Brain, + Hammer, +} from 'lucide-react'; +import { makeRequest } from '@/lib/api'; +import type { + NormalizedConversation, + NormalizedEntryType, + ExecutionProcess, + ApiResponse, +} from 'shared/types'; + +interface NormalizedConversationViewerProps { + executionProcess: ExecutionProcess; + projectId: string; + onConversationUpdate?: () => void; +} + +const getEntryIcon = (entryType: NormalizedEntryType) => { + if (entryType.type === 'user_message') { + return ; + } + if (entryType.type === 'assistant_message') { + return ; + } + if (entryType.type === 'system_message') { + return ; + } + if (entryType.type === 'thinking') { + return ; + } + if (entryType.type === 'tool_use') { + const { action_type } = entryType; + if (action_type.action === 'file_read') { + return ; + } + if (action_type.action === 'file_write') { + return ; + } + if (action_type.action === 'command_run') { + return ; + } + if (action_type.action === 'search') { + return ; + } + if (action_type.action === 'web_fetch') { + return ; + } + if (action_type.action === 'task_create') { + return ; + } + return ; + } + return ; +}; + +const getContentClassName = (entryType: NormalizedEntryType) => { + const baseClasses = 'text-sm whitespace-pre-wrap break-words'; + + if ( + entryType.type === 'tool_use' && + entryType.action_type.action === 'command_run' + ) { + return `${baseClasses} font-mono`; + } + + return baseClasses; +}; + +export function NormalizedConversationViewer({ + executionProcess, + projectId, + onConversationUpdate, +}: NormalizedConversationViewerProps) { + const [conversation, setConversation] = + useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchNormalizedLogs = useCallback( + async (isPolling = false) => { + try { + if (!isPolling) { + setLoading(true); + setError(null); + } + + const response = await makeRequest( + `/api/projects/${projectId}/execution-processes/${executionProcess.id}/normalized-logs` + ); + + if (response.ok) { + const result: ApiResponse = + await response.json(); + if (result.success && result.data) { + setConversation((prev) => { + // Only update if content actually changed + if ( + !prev || + JSON.stringify(prev) !== JSON.stringify(result.data) + ) { + // Notify parent component of conversation update + if (onConversationUpdate) { + // Use setTimeout to ensure state update happens first + setTimeout(onConversationUpdate, 0); + } + return result.data; + } + return prev; + }); + } else if (!isPolling) { + setError(result.message || 'Failed to fetch normalized logs'); + } + } else if (!isPolling) { + const errorText = await response.text(); + setError(`Failed to fetch logs: ${errorText || response.statusText}`); + } + } catch (err) { + if (!isPolling) { + setError( + `Error fetching logs: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + } + } finally { + if (!isPolling) { + setLoading(false); + } + } + }, + [executionProcess.id, projectId, onConversationUpdate] + ); + + // Initial fetch + useEffect(() => { + fetchNormalizedLogs(); + }, [fetchNormalizedLogs]); + + // Auto-refresh every 2 seconds when process is running + useEffect(() => { + if (executionProcess.status === 'running') { + const interval = setInterval(() => { + fetchNormalizedLogs(true); + }, 2000); + + return () => clearInterval(interval); + } + }, [executionProcess.status, fetchNormalizedLogs]); + + if (loading) { + return ( +
+ Loading conversation... +
+ ); + } + + if (error) { + return
{error}
; + } + + if (!conversation || conversation.entries.length === 0) { + // If the execution process is still running, show loading instead of "no data" + if (executionProcess.status === 'running') { + return ( +
+ Waiting for logs... +
+ ); + } + + return ( +
+ No conversation data available +
+ ); + } + + return ( +
+ {/* Display prompt if available */} + {conversation.prompt && ( +
+
+ +
+
+
+ {conversation.prompt} +
+
+
+ )} + + {/* Display conversation entries */} +
+ {conversation.entries.map((entry, index) => ( +
+
+ {getEntryIcon(entry.entry_type)} +
+
+
+ {entry.content} +
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskActivityHistory.tsx b/frontend/src/components/tasks/TaskActivityHistory.tsx index 07c839b0..ad0e6451 100644 --- a/frontend/src/components/tasks/TaskActivityHistory.tsx +++ b/frontend/src/components/tasks/TaskActivityHistory.tsx @@ -3,7 +3,7 @@ import { Clock, ChevronDown, ChevronUp, Code } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Chip } from '@/components/ui/chip'; -import { ExecutionOutputViewer } from './ExecutionOutputViewer'; +import { NormalizedConversationViewer } from './NormalizedConversationViewer'; import type { TaskAttempt, TaskAttemptActivityWithPrompt, @@ -15,6 +15,7 @@ interface TaskActivityHistoryProps { selectedAttempt: TaskAttempt | null; activities: TaskAttemptActivityWithPrompt[]; runningProcessDetails: Record; + projectId: string; } const getAttemptStatusDisplay = ( @@ -63,6 +64,7 @@ export function TaskActivityHistory({ selectedAttempt, activities, runningProcessDetails, + projectId, }: TaskActivityHistoryProps) { const [expandedOutputs, setExpandedOutputs] = useState>( new Set() @@ -163,11 +165,11 @@ export function TaskActivityHistory({ : 'max-h-64 overflow-hidden flex flex-col justify-end' }`} > -
+
+
+
+ {processFileChunks(file.chunks, fileIndex).map( + (section, sectionIndex) => { + if ( + section.type === 'context' && + section.lines.length === 0 && + section.expandKey + ) { + const lineCount = + parseInt(section.expandKey.split('-')[2]) - + parseInt(section.expandKey.split('-')[1]); + return ( +
+ +
+ ); + } + + return ( +
+ {section.type === 'expanded' && + section.expandKey && ( +
+ +
+ )} + {section.lines.map((line, lineIndex) => ( +
+
+ + {line.oldLineNumber || ''} + + + {line.newLineNumber || ''} + +
+
+ + {getChunkPrefix(line.chunkType)} + + {line.content} +
+
+ ))} +
+ ); + } + )} +
+
+
+ ))} +
+ )} + + + {/* Bottom 1/3 - Agent Logs */} +
+
+ {loading ? ( +
+
+

Loading...

+
+ ) : ( + (() => { + // Find main coding agent process (command: "executor") + let mainCodingAgentProcess = Object.values( + attemptData.runningProcessDetails + ).find( + (process) => + process.process_type === 'codingagent' && + process.command === 'executor' + ); + + if (!mainCodingAgentProcess) { + const mainCodingAgentSummary = attemptData.processes.find( + (process) => + process.process_type === 'codingagent' && + process.command === 'executor' + ); + + if (mainCodingAgentSummary) { + mainCodingAgentProcess = Object.values( + attemptData.runningProcessDetails + ).find( + (process) => process.id === mainCodingAgentSummary.id + ); + + if (!mainCodingAgentProcess) { + mainCodingAgentProcess = { + ...mainCodingAgentSummary, + stdout: null, + stderr: null, + } as any; + } + } + } + + // Find follow up executor processes (command: "followup_executor") + const followUpProcesses = attemptData.processes + .filter( + (process) => + process.process_type === 'codingagent' && + process.command === 'followup_executor' + ) + .map((summary) => { + const detailedProcess = Object.values( + attemptData.runningProcessDetails + ).find((process) => process.id === summary.id); + return ( + detailedProcess || + ({ + ...summary, + stdout: null, + stderr: null, + } as any) + ); + }); + + if (mainCodingAgentProcess || followUpProcesses.length > 0) { + return ( +
+ {mainCodingAgentProcess && ( +
+ +
+ )} + {followUpProcesses.map((followUpProcess) => ( +
+
+ +
+ ))} +
+ ); + } + + return ( +
+

No coding agent conversation to display

+
+ ); + })() + )} +
+
+ + ); + } + + // Default case - execution hasn't started or no specific state + return ( +
+
+

Task execution not started yet

+
+
+ ); + }; + if (!task) return null; return ( @@ -162,24 +956,9 @@ export function TaskDetailsPanel({ onSetIsHoveringDevServer={setIsHoveringDevServer} /> - {/* Content */} -
- {loading ? ( -
-
-

Loading...

-
- ) : ( - - )} + {/* Main Content - Dynamic based on execution state */} +
+ {renderMainContent()}
{/* Footer - Follow-up section */} @@ -204,6 +983,48 @@ export function TaskDetailsPanel({ onClose={() => setShowEditorDialog(false)} onSelectEditor={handleOpenInEditor} /> + + {/* Delete File Confirmation Dialog */} + handleCancelDelete()} + > + + + Delete File + + Are you sure you want to delete the file{' '} + + "{fileToDelete}" + + ? + + +
+
+

+ Warning: This action will permanently + remove the entire file from the worktree. This cannot be + undone. +

+
+
+ + + + +
+
)} diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index b00829bb..b4ba8ff8 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,20 +1,23 @@ -import { Link } from 'react-router-dom'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { History, Settings2, StopCircle, Play, - GitCompare, ExternalLink, GitBranch as GitBranchIcon, Search, X, ArrowDown, Plus, + RefreshCw, + FileText, + GitPullRequest, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +25,14 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Tooltip, TooltipContent, @@ -29,6 +40,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { useConfig } from '@/components/config-provider'; +import { makeRequest } from '@/lib/api'; import type { TaskAttempt, TaskWithAttemptStatus, @@ -36,8 +48,15 @@ import type { ExecutionProcess, Project, GitBranch, + BranchStatus, } from 'shared/types'; +interface ApiResponse { + success: boolean; + data: T | null; + message: string | null; +} + interface TaskDetailsToolbarProps { task: TaskWithAttemptStatus; project: Project | null; @@ -104,11 +123,203 @@ export function TaskDetailsToolbar({ const [createAttemptExecutor, setCreateAttemptExecutor] = useState(selectedExecutor); + // Branch status and git operations state + const [branchStatus, setBranchStatus] = useState(null); + const [branchStatusLoading, setBranchStatusLoading] = useState(false); + const [merging, setMerging] = useState(false); + const [rebasing, setRebasing] = useState(false); + const [rebaseSuccess, setRebaseSuccess] = useState(false); + const [showUncommittedWarning, setShowUncommittedWarning] = useState(false); + const [creatingPR, setCreatingPR] = useState(false); + const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const [prBaseBranch, setPrBaseBranch] = useState('main'); + const [error, setError] = useState(null); + // Set create attempt mode when there are no attempts useEffect(() => { setIsInCreateAttemptMode(taskAttempts.length === 0); }, [taskAttempts.length]); + // Branch status fetching + const fetchBranchStatus = useCallback(async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + try { + setBranchStatusLoading(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/branch-status` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setBranchStatus(result.data); + } else { + setError('Failed to load branch status'); + } + } else { + setError('Failed to load branch status'); + } + } catch (err) { + setError('Failed to load branch status'); + } finally { + setBranchStatusLoading(false); + } + }, [projectId, task.id, selectedAttempt?.id]); + + // Fetch branch status when selected attempt changes + useEffect(() => { + if (selectedAttempt) { + fetchBranchStatus(); + } + }, [selectedAttempt, fetchBranchStatus]); + + // Git operations + const handleMergeClick = async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + // Check for uncommitted changes and show warning dialog + if (branchStatus?.has_uncommitted_changes) { + setShowUncommittedWarning(true); + return; + } + + await performMerge(); + }; + + const performMerge = async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + try { + setMerging(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/merge`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + // Refetch branch status to show updated state + fetchBranchStatus(); + } else { + setError('Failed to merge changes'); + } + } else { + setError('Failed to merge changes'); + } + } catch (err) { + setError('Failed to merge changes'); + } finally { + setMerging(false); + } + }; + + const handleConfirmMergeWithUncommitted = async () => { + setShowUncommittedWarning(false); + await performMerge(); + }; + + const handleCancelMergeWithUncommitted = () => { + setShowUncommittedWarning(false); + }; + + const handleRebaseClick = async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + try { + setRebasing(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/rebase`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + setRebaseSuccess(true); + // Refresh branch status after rebase + fetchBranchStatus(); + } else { + setError(result.message || 'Failed to rebase branch'); + } + } else { + setError('Failed to rebase branch'); + } + } catch (err) { + setError('Failed to rebase branch'); + } finally { + setRebasing(false); + } + }; + + const handleCreatePRClick = async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + // Auto-fill with task details if available + setPrTitle(`${task.title} (vibe-kanban)`); + setPrBody(task.description || ''); + + setShowCreatePRDialog(true); + }; + + const handleConfirmCreatePR = async () => { + if (!projectId || !task.id || !selectedAttempt?.id) return; + + try { + setCreatingPR(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/create-pr`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: prTitle, + body: prBody || null, + base_branch: prBaseBranch || null, + }), + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + // Open the PR URL in a new tab + window.open(result.data, '_blank'); + setShowCreatePRDialog(false); + // Reset form + setPrTitle(''); + setPrBody(''); + setPrBaseBranch('main'); + } else { + setError(result.message || 'Failed to create GitHub PR'); + } + } else { + setError('Failed to create GitHub PR'); + } + } catch (err) { + setError('Failed to create GitHub PR'); + } finally { + setCreatingPR(false); + } + }; + + const handleCancelCreatePR = () => { + setShowCreatePRDialog(false); + // Reset form to empty state + setPrTitle(''); + setPrBody(''); + setPrBaseBranch('main'); + }; + // Filter branches based on search term const filteredBranches = useMemo(() => { if (!branchSearchTerm.trim()) { @@ -316,308 +527,499 @@ export function TaskDetailsToolbar({ ); return ( -
- {isInCreateAttemptMode ? ( -
- {renderCreateAttemptUI()} -
- ) : ( -
- {/* Current Attempt Info */} -
- {selectedAttempt ? ( - <> -
-
-
-
- Started -
-
- {new Date( - selectedAttempt.created_at - ).toLocaleDateString()}{' '} - {new Date( - selectedAttempt.created_at - ).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} -
-
- -
-
- Agent -
-
- {availableExecutors.find( - (e) => e.id === selectedAttempt.executor - )?.name || - selectedAttempt.executor || - 'Unknown'} -
-
- -
-
- Base Branch -
-
- - - {selectedBranchDisplayName} - -
-
- -
-
- Merge Status -
-
- {selectedAttempt.merge_commit ? ( -
-
- - Merged - - - ({selectedAttempt.merge_commit.slice(0, 8)}) - -
- ) : ( -
-
- - Not merged - -
- )} -
-
+ <> +
+ {/* Branch Status Display */} + {selectedAttempt && branchStatus && ( +
+
+
+
+ + {branchStatus.up_to_date ? ( + Up to date + ) : branchStatus.is_behind === true ? ( + + {branchStatus.commits_behind} commit + {branchStatus.commits_behind !== 1 ? 's' : ''} behind{' '} + {branchStatus.base_branch_name} + + ) : ( + + {branchStatus.commits_ahead} commit + {branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of{' '} + {branchStatus.base_branch_name} + + )} +
+ {branchStatus.has_uncommitted_changes && ( +
+ + Uncommitted changes
+ )} +
-
-
-
- Worktree Path -
- - - - - - -

Open in editor

-
-
-
-
-
- {selectedAttempt.worktree_path} -
+ {/* Status Messages */} +
+ {branchStatus.merged && ( +
+ ✓ Changes have been merged
+ )} + {rebaseSuccess && ( +
+ Branch rebased successfully! +
+ )} + {error &&
{error}
} +
+
+
+ )} -
-
-
onSetIsHoveringDevServer(true)} - onMouseLeave={() => onSetIsHoveringDevServer(false)} - > + {isInCreateAttemptMode ? ( +
+ {renderCreateAttemptUI()} +
+ ) : ( +
+ {/* Current Attempt Info */} +
+ {selectedAttempt ? ( + <> +
+
+
+
+ Started +
+
+ {new Date( + selectedAttempt.created_at + ).toLocaleDateString()}{' '} + {new Date( + selectedAttempt.created_at + ).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+
+ +
+
+ Agent +
+
+ {availableExecutors.find( + (e) => e.id === selectedAttempt.executor + )?.name || + selectedAttempt.executor || + 'Unknown'} +
+
+ +
+
+ Base Branch +
+
+ + + {selectedBranchDisplayName} + +
+
+ +
+
+ Merge Status +
+
+ {selectedAttempt.merge_commit ? ( +
+
+ + Merged + + + ({selectedAttempt.merge_commit.slice(0, 8)}) + +
+ ) : ( +
+
+ + Not merged + +
+ )} +
+
+
+ +
+
+
+ Worktree Path +
- - {!project?.dev_script ? ( -

- Configure a dev server command in project - settings -

- ) : runningDevServer && devServerDetails ? ( -
-

- Dev Server Logs (Last 10 lines): -

-
-                                    {processedDevServerLogs}
-                                  
-
- ) : runningDevServer ? ( -

Stop the running dev server

- ) : ( -

Start the dev server

- )} + +

Open in editor

- - +
+ {selectedAttempt.worktree_path} +
-
- {taskAttempts.length > 1 && ( - +
+
+
onSetIsHoveringDevServer(true)} + onMouseLeave={() => onSetIsHoveringDevServer(false)} + > - - - + - -

View attempt history

+ + {!project?.dev_script ? ( +

+ Configure a dev server command in project + settings +

+ ) : runningDevServer && devServerDetails ? ( +
+

+ Dev Server Logs (Last 10 lines): +

+
+                                      {processedDevServerLogs}
+                                    
+
+ ) : runningDevServer ? ( +

Stop the running dev server

+ ) : ( +

Start the dev server

+ )}
- - {taskAttempts.map((attempt) => ( - onAttemptChange(attempt.id)} - className={ - selectedAttempt?.id === attempt.id - ? 'bg-accent' - : '' - } - > -
- - {new Date( - attempt.created_at - ).toLocaleDateString()}{' '} - {new Date( - attempt.created_at - ).toLocaleTimeString()} - - - {attempt.executor || 'executor'} - -
-
- ))} -
- - )} +
+
- {isStopping || isAttemptRunning ? ( - - ) : ( - - )} +
+ {taskAttempts.length > 1 && ( + + + + + + + + + +

View attempt history

+
+
+
+ + {taskAttempts.map((attempt) => ( + onAttemptChange(attempt.id)} + className={ + selectedAttempt?.id === attempt.id + ? 'bg-accent' + : '' + } + > +
+ + {new Date( + attempt.created_at + ).toLocaleDateString()}{' '} + {new Date( + attempt.created_at + ).toLocaleTimeString()} + + + {attempt.executor || 'executor'} + +
+
+ ))} +
+
+ )} + + {/* Git Operations */} + {selectedAttempt && branchStatus && ( + <> + {branchStatus.is_behind === true && + !branchStatus.merged && ( + + )} + {!branchStatus.merged && ( + <> + + + + )} + + )} + + {isStopping || isAttemptRunning ? ( + + ) : ( + + )} +
+ + ) : ( +
+
+ No attempts yet +
+
+ Start your first attempt to begin working on this task +
- - ) : ( -
-
- No attempts yet -
-
- Start your first attempt to begin working on this task -
+ )} +
+ + {/* Special Actions */} + {!selectedAttempt && !isAttemptRunning && !isStopping && ( +
+
)}
+ )} +
- {/* Special Actions */} - {!selectedAttempt && !isAttemptRunning && !isStopping && ( -
- + {/* Uncommitted Changes Warning Dialog */} + handleCancelMergeWithUncommitted()} + > + + + Uncommitted Changes Detected + + There are uncommitted changes in the worktree that will be + included in the merge. + + +
+
+

+ Warning: The worktree contains uncommitted + changes (modified, added, or deleted files) that have not been + committed to git. These changes will be permanently merged into + the {branchStatus?.base_branch_name || 'base'} branch. +

- )} -
- )} -
+
+ + + + + + + + {/* Create PR Dialog */} + handleCancelCreatePR()} + > + + + Create GitHub Pull Request + + Create a pull request for this task attempt on GitHub. + + +
+
+ + setPrTitle(e.target.value)} + placeholder="Enter PR title" + /> +
+
+ +