diff --git a/.tmux.conf b/.tmux.conf index 9d63c296..4b41dbe9 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -1,4 +1,4 @@ -# User-friendly tmux configuration for Ushadow environments +# Global tmux configuration # Enable mouse support (scroll, select, resize panes) set -g mouse on @@ -29,3 +29,12 @@ set -g pane-active-border-style fg=colour39 # Fix mouse scrolling in terminal applications set -g terminal-overrides 'xterm*:smcup@:rmcup@' + +# macOS clipboard integration +# Mouse drag to select text in copy mode automatically copies to system clipboard +set -g set-clipboard on +bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" +bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" + +# Paste with prefix+p from tmux buffer, or just use Cmd+V in iTerm +bind-key p run "pbpaste | tmux load-buffer - && tmux paste-buffer" diff --git a/.workmux.yaml b/.workmux.yaml index 223f0483..c897029e 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -27,6 +27,8 @@ post_create: # Commands to run before merging (aborts merge if any fail) pre_merge: + # Update ticket status to done when merging + - "kanban-cli move-to-done \"$WM_WORKTREE_PATH\" || true" # Ensure tests pass before merging # - "make test" # Uncomment when test suite is ready diff --git a/scripts/register-one-worktree.sh b/scripts/register-one-worktree.sh new file mode 100755 index 00000000..5424c726 --- /dev/null +++ b/scripts/register-one-worktree.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Quick helper to register a single worktree with workmux +# Usage: ./register-one-worktree.sh blue + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + echo "Example: $0 blue" + exit 1 +fi + +WORKTREE_NAME="$1" +WORKTREE_PATH="/Users/stu/repos/worktrees/ushadow/$WORKTREE_NAME" + +if [[ ! -d "$WORKTREE_PATH" ]]; then + echo "❌ Worktree not found: $WORKTREE_PATH" + exit 1 +fi + +if [[ -z "${TMUX:-}" ]]; then + echo "❌ Must be run from inside a tmux session" + echo " Run: tmux attach -t workmux" + exit 1 +fi + +echo "πŸ“ Registering worktree: $WORKTREE_NAME" +cd "$WORKTREE_PATH" + +if workmux open "$WORKTREE_NAME" 2>&1 | tee /tmp/workmux-open.log | grep -q "Opened tmux window"; then + echo "βœ… Successfully registered!" + echo " Run 'workmux list' to verify" +else + echo "❌ Failed to register. Error:" + cat /tmp/workmux-open.log + exit 1 +fi diff --git a/scripts/register-worktrees-with-workmux.sh b/scripts/register-worktrees-with-workmux.sh new file mode 100755 index 00000000..4f6694aa --- /dev/null +++ b/scripts/register-worktrees-with-workmux.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Register existing launcher-created worktrees with workmux for dashboard visibility +# This is a one-time migration script + +set -euo pipefail + +WORKTREES_DIR="/Users/stu/repos/worktrees/ushadow" +MAIN_REPO="/Users/stu/repos/Ushadow" + +echo "πŸ”„ Registering existing worktrees with workmux..." +echo "" + +# Counter for stats +registered=0 +skipped=0 +failed=0 + +# Iterate through all worktree directories +for worktree_path in "$WORKTREES_DIR"/*; do + # Skip if not a directory + if [[ ! -d "$worktree_path" ]]; then + continue + fi + + # Get the worktree name (directory basename) + worktree_name=$(basename "$worktree_path") + + # Skip special directories + if [[ "$worktree_name" == "." || "$worktree_name" == ".." || "$worktree_name" == ".DS_Store" || "$worktree_name" == ".serena" ]]; then + continue + fi + + # Check if it's actually a git worktree (linked worktrees have .git as a file, not directory) + if [[ ! -e "$worktree_path/.git" ]]; then + echo "⚠️ Skipping $worktree_name (not a git worktree)" + ((skipped++)) + continue + fi + + echo "πŸ“ Registering: $worktree_name" + + # Register with workmux (by default, it doesn't run hooks or file operations) + if (cd "$worktree_path" && workmux open "$worktree_name" 2>&1 | grep -q "Opened tmux window"); then + echo " βœ… Successfully registered" + ((registered++)) + else + echo " ❌ Failed to register" + ((failed++)) + fi + echo "" +done + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "πŸ“Š Registration Summary:" +echo " βœ… Registered: $registered" +echo " ⚠️ Skipped: $skipped" +echo " ❌ Failed: $failed" +echo "" +echo "πŸ’‘ Run 'workmux dashboard' to see your worktrees!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/ushadow/backend/src/config/keycloak_settings.py b/ushadow/backend/src/config/keycloak_settings.py index fe7547c1..7694df51 100644 --- a/ushadow/backend/src/config/keycloak_settings.py +++ b/ushadow/backend/src/config/keycloak_settings.py @@ -103,7 +103,7 @@ def get_keycloak_admin() -> KeycloakAdmin: settings = get_settings() # Get application realm to manage - app_realm = settings.get_sync("keycloak.realm", "master") + app_realm = settings.get_sync("keycloak.realm", "ushadow") # Internal URL for backend-to-Keycloak communication # Resolved by OmegaConf: ${oc.env:KC_URL,http://keycloak:8080} diff --git a/ushadow/launcher/.claude/hooks/idle-notification.sh b/ushadow/launcher/.claude/hooks/idle-notification.sh index 9d3ce076..e0f86308 100755 --- a/ushadow/launcher/.claude/hooks/idle-notification.sh +++ b/ushadow/launcher/.claude/hooks/idle-notification.sh @@ -3,17 +3,14 @@ # Move ticket to in_review when agent is waiting for user input # Log for debugging -echo "[$(date)] idle-notification hook fired" >> /tmp/claude-kanban-hooks.log +echo "[$(date)] idle-notification hook fired in $(pwd)" >> /tmp/claude-kanban-hooks.log -BRANCH=$(git branch --show-current 2>/dev/null) - -if [ -z "$BRANCH" ]; then - exit 0 -fi +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" if command -v kanban-cli >/dev/null 2>&1; then - kanban-cli move-to-review "$BRANCH" 2>/dev/null - echo "[$(date)] Moved $BRANCH to review" >> /tmp/claude-kanban-hooks.log + kanban-cli move-to-review "$WORKTREE_PATH" 2>/dev/null + echo "[$(date)] Moved ticket at $WORKTREE_PATH to review" >> /tmp/claude-kanban-hooks.log fi exit 0 diff --git a/ushadow/launcher/.claude/hooks/session-end.sh b/ushadow/launcher/.claude/hooks/session-end.sh index 1289a999..09258c8d 100755 --- a/ushadow/launcher/.claude/hooks/session-end.sh +++ b/ushadow/launcher/.claude/hooks/session-end.sh @@ -2,14 +2,11 @@ # Claude Code SessionEnd hook - agent session ending # Move ticket to in_review (waiting for human to review/respond) -BRANCH=$(git branch --show-current 2>/dev/null) - -if [ -z "$BRANCH" ]; then - exit 0 -fi +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" if command -v kanban-cli >/dev/null 2>&1; then - kanban-cli move-to-review "$BRANCH" 2>/dev/null + kanban-cli move-to-review "$WORKTREE_PATH" 2>/dev/null fi exit 0 diff --git a/ushadow/launcher/.claude/hooks/session-start.sh b/ushadow/launcher/.claude/hooks/session-start.sh index d23e8c00..6a426a3a 100755 --- a/ushadow/launcher/.claude/hooks/session-start.sh +++ b/ushadow/launcher/.claude/hooks/session-start.sh @@ -2,14 +2,11 @@ # Claude Code SessionStart hook - agent session just started # Move ticket to in_progress -BRANCH=$(git branch --show-current 2>/dev/null) - -if [ -z "$BRANCH" ]; then - exit 0 -fi +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" if command -v kanban-cli >/dev/null 2>&1; then - kanban-cli move-to-progress "$BRANCH" 2>/dev/null + kanban-cli move-to-progress "$WORKTREE_PATH" 2>/dev/null fi exit 0 diff --git a/ushadow/launcher/.claude/hooks/user-prompt-submit.sh b/ushadow/launcher/.claude/hooks/user-prompt-submit.sh index 652975b9..faeb8143 100755 --- a/ushadow/launcher/.claude/hooks/user-prompt-submit.sh +++ b/ushadow/launcher/.claude/hooks/user-prompt-submit.sh @@ -2,14 +2,11 @@ # Claude Code UserPromptSubmit hook - user just submitted a prompt # Move ticket to in_progress (agent resuming work after waiting) -BRANCH=$(git branch --show-current 2>/dev/null) - -if [ -z "$BRANCH" ]; then - exit 0 -fi +# Use worktree path (more reliable than branch name) +WORKTREE_PATH="$(pwd)" if command -v kanban-cli >/dev/null 2>&1; then - kanban-cli move-to-progress "$BRANCH" 2>/dev/null + kanban-cli move-to-progress "$WORKTREE_PATH" 2>/dev/null fi exit 0 diff --git a/ushadow/launcher/.claude/settings.local.json b/ushadow/launcher/.claude/settings.local.json index 5abaafbe..1c64dc02 100644 --- a/ushadow/launcher/.claude/settings.local.json +++ b/ushadow/launcher/.claude/settings.local.json @@ -4,7 +4,56 @@ "Bash(git rebase:*)", "Bash(git merge:*)", "Bash(git checkout:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(kanban-cli:*)" + ] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/session-start.sh", + "async": true + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/user-prompt-submit.sh", + "async": true + } + ] + } + ], + "Notification": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/idle-notification.sh", + "async": true + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/session-end.sh", + "async": true + } + ] + } ] } } diff --git a/ushadow/launcher/dist/index.html b/ushadow/launcher/dist/index.html index 32c42a44..73013c03 100644 --- a/ushadow/launcher/dist/index.html +++ b/ushadow/launcher/dist/index.html @@ -5,8 +5,8 @@ Ushadow Launcher - - + +
diff --git a/ushadow/launcher/package.json b/ushadow/launcher/package.json index 3fe4d0db..7e78c3b1 100644 --- a/ushadow/launcher/package.json +++ b/ushadow/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ushadow-launcher", - "version": "0.8.0", + "version": "0.8.1", "description": "Ushadow Desktop Launcher", "private": true, "type": "module", diff --git a/ushadow/launcher/src-tauri/Cargo.lock b/ushadow/launcher/src-tauri/Cargo.lock index af48cd0a..96606035 100644 --- a/ushadow/launcher/src-tauri/Cargo.lock +++ b/ushadow/launcher/src-tauri/Cargo.lock @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "ushadow-launcher" -version = "0.8.0" +version = "0.8.1" dependencies = [ "chrono", "dirs", diff --git a/ushadow/launcher/src-tauri/Cargo.toml b/ushadow/launcher/src-tauri/Cargo.toml index bc542963..22770850 100644 --- a/ushadow/launcher/src-tauri/Cargo.toml +++ b/ushadow/launcher/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ushadow-launcher" -version = "0.8.0" +version = "0.8.1" description = "Ushadow Desktop Launcher" authors = ["Ushadow"] license = "MIT" diff --git a/ushadow/launcher/src-tauri/src/commands/kanban.rs b/ushadow/launcher/src-tauri/src/commands/kanban.rs index 55cfb13c..b83a02af 100644 --- a/ushadow/launcher/src-tauri/src/commands/kanban.rs +++ b/ushadow/launcher/src-tauri/src/commands/kanban.rs @@ -6,12 +6,46 @@ use std::fs; use serde::{Deserialize, Serialize}; use tauri::api::path::data_dir; +/// Write a `.claude/settings.local.json` into a worktree so Claude Code hooks +/// fire and update kanban ticket status automatically. +/// Skips silently if the file already exists (preserves user customisations). +fn setup_claude_hooks(worktree_path: &str) { + let settings_path = format!("{}/.claude/settings.local.json", worktree_path); + if std::path::Path::new(&settings_path).exists() { + eprintln!("[setup_claude_hooks] settings.local.json already exists, skipping"); + return; + } + if let Err(e) = std::fs::create_dir_all(format!("{}/.claude", worktree_path)) { + eprintln!("[setup_claude_hooks] Failed to create .claude dir: {}", e); + return; + } + let content = r#"{ + "skipDangerousModePermissionPrompt": true, + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-progress \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-progress \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "Notification": [{"matcher": "idle_prompt", "hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-review \"$(pwd)\" 2>/dev/null; exit 0"}]}], + "Stop": [{"hooks": [{"type": "command", "async": true, + "command": "command -v kanban-cli >/dev/null 2>&1 && kanban-cli move-to-review \"$(pwd)\" 2>/dev/null; exit 0"}]}] + } +} +"#; + match std::fs::write(&settings_path, content) { + Ok(_) => eprintln!("[setup_claude_hooks] βœ“ Wrote {}", settings_path), + Err(e) => eprintln!("[setup_claude_hooks] Failed to write {}: {}", settings_path, e), + } +} + /// Request to create a ticket with worktree and tmux #[derive(Debug, Deserialize)] pub struct CreateTicketWorktreeRequest { pub ticket_id: String, pub ticket_title: String, pub project_root: String, + pub environment_name: String, // Simple name for worktree directory (e.g., "staging") pub branch_name: Option, // If None, will be generated from ticket_id pub base_branch: Option, // Default to "main" pub epic_branch: Option, // If part of epic with shared branch @@ -50,25 +84,30 @@ pub async fn create_ticket_worktree( format!("ticket-{}", request.ticket_id) }; - let base_branch = request.base_branch.unwrap_or_else(|| "main".to_string()); + // Create a unique window name for this ticket (include ticket ID to ensure uniqueness) + // Sanitize branch_name by replacing slashes and other special chars with dashes (tmux doesn't allow slashes) + let sanitized_branch = branch_name.replace('/', "-").replace('\\', "-"); + let ticket_id_short = &request.ticket_id[request.ticket_id.len().saturating_sub(6)..]; // Last 6 chars + let tmux_window_name = format!("ushadow-{}-{}", sanitized_branch, ticket_id_short); + let tmux_session_name = "workmux".to_string(); // Default session // Create worktree with tmux integration - // The worktree name will be the branch name + // Pass the custom window name so it creates the window with the ticket ID included + // Use environment_name for the worktree directory, branch_name for the git branch let worktree_info = create_worktree_with_workmux( request.project_root.clone(), - branch_name.clone(), - Some(base_branch), - Some(false), // Not background + request.environment_name.clone(), // name: worktree directory name (simple name like "staging") + Some(branch_name.clone()), // branch_name: desired branch name (full name like "staging/feature-main") + request.base_branch.clone(), // base_branch: branch to create from + Some(false), // background: not background + Some(tmux_window_name.clone()), // custom_window_name: use ticket-specific name ).await?; - // Create a unique window name for this ticket (include ticket ID to ensure uniqueness) - let ticket_id_short = &request.ticket_id[request.ticket_id.len().saturating_sub(6)..]; // Last 6 chars - let tmux_window_name = format!("ushadow-{}-{}", branch_name, ticket_id_short); - let tmux_session_name = "workmux".to_string(); // Default session - eprintln!("[create_ticket_worktree] βœ“ Worktree created at: {}", worktree_info.path); eprintln!("[create_ticket_worktree] βœ“ Tmux window: {}", tmux_window_name); + setup_claude_hooks(&worktree_info.path); + Ok(CreateTicketWorktreeResult { worktree_path: worktree_info.path, branch_name, @@ -93,11 +132,43 @@ pub async fn attach_ticket_to_worktree( return Err(format!("Worktree path does not exist: {}", worktree_path)); } - // Create a unique window name for this ticket (include ticket ID to ensure uniqueness) - let ticket_id_short = &ticket_id[ticket_id.len().saturating_sub(6)..]; // Last 6 chars - let tmux_window_name = format!("ushadow-{}-{}", branch_name, ticket_id_short); let tmux_session_name = "workmux".to_string(); + // First, try to find an existing tmux window for this worktree path + // This is more reliable than using the branch name, which might be stale + let find_window_cmd = format!( + "tmux list-windows -t {} -F '#{{window_name}}:#{{pane_current_path}}' 2>/dev/null | grep ':{}' | head -1 | cut -d: -f1", + tmux_session_name, + worktree_path.replace("'", "'\\''") // Escape single quotes for shell safety + ); + + let find_result = shell_command(&find_window_cmd).output(); + + let existing_window = find_result + .ok() + .and_then(|output| { + if output.status.success() { + let window = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !window.is_empty() { + Some(window) + } else { + None + } + } else { + None + } + }); + + let tmux_window_name = if let Some(ref existing) = existing_window { + eprintln!("[attach_ticket_to_worktree] βœ“ Found existing tmux window for this worktree: {}", existing); + existing.clone() + } else { + // No existing window found, create a new unique window name + eprintln!("[attach_ticket_to_worktree] No existing window found, will create new one"); + let ticket_id_short = &ticket_id[ticket_id.len().saturating_sub(6)..]; // Last 6 chars + format!("ushadow-{}-{}", branch_name, ticket_id_short) + }; + // Ensure tmux server is running shell_command("tmux start-server") .output() @@ -114,43 +185,67 @@ pub async fn attach_ticket_to_worktree( .map_err(|e| format!("Failed to create workmux session: {}", e))?; } - // Check if tmux window exists - let check_window = shell_command(&format!( - "tmux list-windows -t {} -F '#W'", - tmux_session_name - )) - .output() - .map_err(|e| format!("Failed to check tmux windows: {}", e))?; + // Only need to create window if we didn't find an existing one + if existing_window.is_none() { + // Check if tmux window exists (by the new name we generated) + let check_window = shell_command(&format!( + "tmux list-windows -t {} -F '#W'", + tmux_session_name + )) + .output() + .map_err(|e| format!("Failed to check tmux windows: {}", e))?; - let stdout = String::from_utf8_lossy(&check_window.stdout); - let window_exists = stdout.lines().any(|line| line == tmux_window_name); + let stdout = String::from_utf8_lossy(&check_window.stdout); + let window_exists = stdout.lines().any(|line| line == tmux_window_name); - if window_exists { - eprintln!("[attach_ticket_to_worktree] βœ“ Found existing tmux window: {}", tmux_window_name); - } else { - eprintln!("[attach_ticket_to_worktree] Creating tmux window: {}", tmux_window_name); + if !window_exists { + eprintln!("[attach_ticket_to_worktree] Creating new tmux window: {}", tmux_window_name); - // Create the tmux window - let create_result = shell_command(&format!( - "tmux new-window -t {} -n {} -c '{}'", - tmux_session_name, tmux_window_name, worktree_path - )) - .output() - .map_err(|e| format!("Failed to create tmux window: {}", e))?; + // Create the tmux window + let create_result = shell_command(&format!( + "tmux new-window -t {} -n {} -c '{}'", + tmux_session_name, tmux_window_name, worktree_path + )) + .output() + .map_err(|e| format!("Failed to create tmux window: {}", e))?; - if !create_result.status.success() { - let stderr = String::from_utf8_lossy(&create_result.stderr); - return Err(format!("Failed to create tmux window: {}", stderr)); - } + if !create_result.status.success() { + let stderr = String::from_utf8_lossy(&create_result.stderr); + return Err(format!("Failed to create tmux window: {}", stderr)); + } - eprintln!("[attach_ticket_to_worktree] βœ“ Created tmux window: {}", tmux_window_name); + eprintln!("[attach_ticket_to_worktree] βœ“ Created tmux window: {}", tmux_window_name); + } else { + eprintln!("[attach_ticket_to_worktree] βœ“ Window already exists: {}", tmux_window_name); + } } + // Get the actual current branch from the worktree (more reliable than env data) + let actual_branch = shell_command(&format!("cd '{}' && git branch --show-current", worktree_path)) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() { + Some(branch) + } else { + None + } + } else { + None + } + }) + .unwrap_or(branch_name); // Fallback to provided branch_name if detection fails + eprintln!("[attach_ticket_to_worktree] βœ“ Ticket attached to worktree with tmux window: {}", tmux_window_name); + eprintln!("[attach_ticket_to_worktree] βœ“ Actual branch: {}", actual_branch); + + setup_claude_hooks(&worktree_path); Ok(CreateTicketWorktreeResult { worktree_path, - branch_name, + branch_name: actual_branch, tmux_window_name, tmux_session_name, }) @@ -567,20 +662,25 @@ pub async fn update_ticket( if let Some(o) = order { ticket.order = o; } + // Handle worktree_path: empty string means clear, non-empty means set if let Some(wp) = worktree_path { - ticket.worktree_path = Some(wp); + ticket.worktree_path = if wp.is_empty() { None } else { Some(wp) }; } + // Handle branch_name: empty string means clear, non-empty means set if let Some(bn) = branch_name { - ticket.branch_name = Some(bn); + ticket.branch_name = if bn.is_empty() { None } else { Some(bn) }; } + // Handle tmux_window_name: empty string means clear, non-empty means set if let Some(twn) = tmux_window_name { - ticket.tmux_window_name = Some(twn); + ticket.tmux_window_name = if twn.is_empty() { None } else { Some(twn) }; } + // Handle tmux_session_name: empty string means clear, non-empty means set if let Some(tsn) = tmux_session_name { - ticket.tmux_session_name = Some(tsn); + ticket.tmux_session_name = if tsn.is_empty() { None } else { Some(tsn) }; } + // Handle environment_name: empty string means clear, non-empty means set if let Some(en) = environment_name { - ticket.environment_name = Some(en); + ticket.environment_name = if en.is_empty() { None } else { Some(en) }; } ticket.updated_at = chrono::Utc::now().to_rfc3339(); @@ -813,114 +913,104 @@ pub async fn start_coding_agent_for_ticket( ticket.description.as_ref().unwrap_or(&"No description".to_string()) ); - // Build the command to send to tmux - // Format: tmux send-keys -t session:window "command" Enter - let agent_command = if settings.coding_agent.args.is_empty() { + // Build the base agent command from settings + let base_agent_command = if settings.coding_agent.args.is_empty() { settings.coding_agent.command.clone() } else { format!("{} {}", settings.coding_agent.command, settings.coding_agent.args.join(" ")) }; - eprintln!("[start_coding_agent_for_ticket] Running agent command: {}", agent_command); + // Always enable agent teams so any running instance can become a lead + // and split panes can coordinate when multiple tickets land in the same worktree. + let agent_command = format!("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 {}", base_agent_command); - // First verify the tmux window exists - let check_window = shell_command(&format!( + // Verify the tmux window exists + let windows_output = shell_command(&format!( "tmux list-windows -t {} -F '#{{window_name}}'", tmux_session_name )) .output() + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .map_err(|e| format!("Failed to check tmux windows: {}", e))?; - let windows_output = String::from_utf8_lossy(&check_window.stdout); - eprintln!("[start_coding_agent_for_ticket] Available windows in session {}:", tmux_session_name); - eprintln!("{}", windows_output); - if !windows_output.contains(&tmux_window_name) { return Err(format!("Tmux window '{}' not found in session '{}'", tmux_window_name, tmux_session_name)); } - // Send a test echo command first to verify tmux communication works - let test_cmd = format!("tmux send-keys -t {}:{} 'echo \"[LAUNCHER] Starting coding agent...\"' Enter", tmux_session_name, tmux_window_name); - eprintln!("[start_coding_agent_for_ticket] Test command: {}", test_cmd); - let test_result = shell_command(&test_cmd) - .output() - .map_err(|e| format!("Failed to send test command: {}", e))?; - - if !test_result.status.success() { - let stderr = String::from_utf8_lossy(&test_result.stderr); - return Err(format!("Test command failed: {}", stderr)); - } - - std::thread::sleep(std::time::Duration::from_millis(300)); - - // CD to worktree directory - let cd_cmd = format!("tmux send-keys -t {}:{} 'cd \"{}\"' Enter", tmux_session_name, tmux_window_name, worktree_path); - eprintln!("[start_coding_agent_for_ticket] CD command: {}", cd_cmd); - let cd_result = shell_command(&cd_cmd) + // Check if a Claude session is already running in this window. + let current_command = shell_command(&format!( + "tmux display-message -t {}:{} -p '#{{pane_current_command}}'", + tmux_session_name, tmux_window_name + )) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + let is_shell = matches!(current_command.as_str(), "zsh" | "bash" | "sh" | "fish" | ""); + + if !is_shell { + // An agent (likely Claude) is already running β€” it becomes the team lead. + // Ask it to spawn a teammate for the new ticket rather than starting a second + // Claude instance ourselves. The lead's agent teams support handles pane splitting + // and task coordination natively. + eprintln!( + "[start_coding_agent_for_ticket] Lead agent '{}' running β€” delegating new ticket via agent teams", + current_command + ); + + let spawn_request = format!( + "A new ticket has been assigned to this workspace. \ + Please spawn a teammate using agent teams to work on it in a split pane. \ + Title: {} β€” Description: {}", + ticket.title, + ticket.description.as_ref().unwrap_or(&"No description".to_string()) + ); + + let escaped = spawn_request + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`"); + + shell_command(&format!( + "tmux send-keys -t {}:{} \"{}\" Enter", + tmux_session_name, tmux_window_name, escaped + )) .output() - .map_err(|e| format!("Failed to send cd command: {}", e))?; + .map_err(|e| format!("Failed to send teammate request to lead: {}", e))?; - if !cd_result.status.success() { - let stderr = String::from_utf8_lossy(&cd_result.stderr); - return Err(format!("CD command failed: {}", stderr)); + eprintln!("[start_coding_agent_for_ticket] βœ“ Teammate request sent to lead"); + return Ok(()); } - std::thread::sleep(std::time::Duration::from_millis(300)); + // No agent running β€” start Claude fresh as the (future) team lead. + // Pass the ticket context as a CLI argument so there's no timing dependency. + // Claude accepts: claude [flags] "initial prompt" + // This is far more reliable than send-keys-wait-send-keys. + let escaped_prompt = prompt + .replace('\'', "'\\''"); // single-quote safe escaping for the shell - // Send PWD to verify we're in the right directory - let pwd_cmd = format!("tmux send-keys -t {}:{} 'pwd' Enter", tmux_session_name, tmux_window_name); - eprintln!("[start_coding_agent_for_ticket] PWD command: {}", pwd_cmd); - shell_command(&pwd_cmd) - .output() - .map_err(|e| format!("Failed to send pwd command: {}", e))?; + let full_command = format!("{} '{}'", agent_command, escaped_prompt); + eprintln!("[start_coding_agent_for_ticket] Starting agent with prompt arg"); - std::thread::sleep(std::time::Duration::from_millis(500)); + // CD then start in one shot to avoid a separate sleep + let cmd = format!( + "tmux send-keys -t {}:{} 'cd \"{}\" && {}' Enter", + tmux_session_name, tmux_window_name, worktree_path, full_command + ); - // Finally, start the coding agent - let agent_cmd = format!("tmux send-keys -t {}:{} '{}' Enter", tmux_session_name, tmux_window_name, agent_command); - eprintln!("[start_coding_agent_for_ticket] Agent command: {}", agent_cmd); - let start_agent = shell_command(&agent_cmd) + let start_agent = shell_command(&cmd) .output() - .map_err(|e| format!("Failed to send agent command: {}", e))?; + .map_err(|e| format!("Failed to start agent: {}", e))?; if !start_agent.status.success() { - let stderr = String::from_utf8_lossy(&start_agent.stderr); - return Err(format!("Failed to start coding agent: {}", stderr)); + return Err(format!( + "Failed to start coding agent: {}", + String::from_utf8_lossy(&start_agent.stderr) + )); } - // Wait for agent to start up - eprintln!("[start_coding_agent_for_ticket] Waiting for agent to start..."); - std::thread::sleep(std::time::Duration::from_secs(3)); - - // Send the ticket context as a prompt - // We need to escape the prompt for shell safety - let escaped_prompt = prompt - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("$", "\\$") - .replace("`", "\\`"); - - let prompt_cmd = format!("tmux send-keys -t {}:{} \"{}\"", tmux_session_name, tmux_window_name, escaped_prompt); - eprintln!("[start_coding_agent_for_ticket] Sending ticket prompt to agent..."); - let send_prompt = shell_command(&prompt_cmd) - .output() - .map_err(|e| format!("Failed to send prompt: {}", e))?; - - if !send_prompt.status.success() { - let stderr = String::from_utf8_lossy(&send_prompt.stderr); - eprintln!("[start_coding_agent_for_ticket] Warning: Failed to send prompt: {}", stderr); - // Don't fail the whole operation if prompt sending fails - } - - // Send Enter to submit the prompt - std::thread::sleep(std::time::Duration::from_millis(500)); - let enter_cmd = format!("tmux send-keys -t {}:{} Enter", tmux_session_name, tmux_window_name); - shell_command(&enter_cmd) - .output() - .map_err(|e| format!("Failed to send Enter: {}", e))?; - - eprintln!("[start_coding_agent_for_ticket] βœ“ All commands sent successfully"); - + eprintln!("[start_coding_agent_for_ticket] βœ“ Agent started with ticket context"); Ok(()) } @@ -954,6 +1044,50 @@ fn get_next_ticket_number(conn: &rusqlite::Connection, prefix: &str) -> Result Option { + let conn = get_db_connection().ok()?; + let mut stmt = conn.prepare( + "SELECT * FROM tickets WHERE worktree_path = ? AND status != 'done' AND status != 'archived' ORDER BY updated_at DESC LIMIT 1" + ).ok()?; + + stmt.query_row([worktree_path], |row| { + Ok(Ticket { + id: row.get(0)?, + title: row.get(1)?, + description: row.get(2)?, + status: match row.get::<_, String>(3)?.as_str() { + "backlog" => TicketStatus::Backlog, + "todo" => TicketStatus::Todo, + "in_progress" => TicketStatus::InProgress, + "in_review" => TicketStatus::InReview, + "done" => TicketStatus::Done, + "archived" => TicketStatus::Archived, + _ => TicketStatus::Backlog, + }, + priority: match row.get::<_, String>(4)?.as_str() { + "low" => TicketPriority::Low, + "medium" => TicketPriority::Medium, + "high" => TicketPriority::High, + "urgent" => TicketPriority::Urgent, + _ => TicketPriority::Medium, + }, + epic_id: row.get(5)?, + tags: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(), + color: row.get(7)?, + tmux_window_name: row.get(8)?, + tmux_session_name: row.get(9)?, + branch_name: row.get(10)?, + worktree_path: row.get(11)?, + environment_name: row.get(12)?, + project_id: row.get(13)?, + assigned_to: row.get(14)?, + order: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }).ok() +} + fn get_ticket_by_id(id: &str) -> Result { let conn = get_db_connection()?; diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index b380c18e..d3b95fdd 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -273,6 +273,7 @@ pub async fn create_worktree( main_repo: String, worktrees_dir: String, name: String, + branch_name: Option, base_branch: Option, ) -> Result { // Force lowercase to avoid Docker Compose naming issues @@ -297,7 +298,7 @@ pub async fn create_worktree( } // Determine the desired branch name (also lowercase) - let desired_branch = base_branch.map(|b| b.to_lowercase()).unwrap_or_else(|| name.clone()); + let desired_branch = branch_name.map(|b| b.to_lowercase()).unwrap_or_else(|| name.clone()); // Check if git has this worktree registered or if the branch is in use let list_output = silent_command("git") @@ -454,24 +455,47 @@ pub async fn create_worktree( .map_err(|e| format!("Failed to create worktree: {}", e))?; (output, desired_branch) } else { - // Branch doesn't exist - create new branch from remote base branch - // Parse branch name to determine base branch (e.g., rouge/myfeature-dev -> origin/dev) + // Branch doesn't exist - create new branch from base branch let new_branch_name = desired_branch.clone(); - // Determine base from branch suffix (-dev or -main) - let base = if new_branch_name.ends_with("-dev") { - "origin/dev" + // Determine base branch to use + // Priority: 1) Provided base_branch parameter, 2) Derive from suffix, 3) Default to origin/main + let base = if let Some(ref provided_base) = base_branch { + // Use provided base branch - could be origin/main, origin/dev, or another branch like rouge/feature-dev + if provided_base.contains('/') { + // Already has a prefix (e.g., "origin/dev" or "rouge/feature-dev") + provided_base.clone() + } else { + // Check if branch exists locally first + let local_check = silent_command("git") + .args(["rev-parse", "--verify", provided_base]) + .current_dir(&main_repo) + .output() + .ok() + .map(|o| o.status.success()) + .unwrap_or(false); + + if local_check { + eprintln!("[create_worktree] Using local branch '{}'", provided_base); + provided_base.clone() // Use local branch as-is + } else { + eprintln!("[create_worktree] Local branch '{}' not found, trying origin/{}", provided_base, provided_base); + format!("origin/{}", provided_base) // Try remote + } + } + } else if new_branch_name.ends_with("-dev") { + "origin/dev".to_string() } else if new_branch_name.ends_with("-main") { - "origin/main" + "origin/main".to_string() } else { // Default to origin/main if no suffix - "origin/main" + "origin/main".to_string() }; eprintln!("[create_worktree] Creating new branch '{}' from '{}'", new_branch_name, base); let output = silent_command("git") - .args(["worktree", "add", "-b", &new_branch_name, worktree_path.to_str().unwrap(), base]) + .args(["worktree", "add", "-b", &new_branch_name, worktree_path.to_str().unwrap(), &base]) .current_dir(&main_repo) .output() .map_err(|e| format!("Failed to create worktree: {}", e))?; @@ -538,7 +562,9 @@ async fn open_in_vscode_impl(path: String, env_name: Option, with_tmux: // If with_tmux is true, create a shell script that VS Code can run if with_tmux && env_name.is_some() { let env_name_lower = env_name.unwrap().to_lowercase(); - let window_name = format!("ushadow-{}", env_name_lower); + // Sanitize env_name by replacing slashes (tmux doesn't allow slashes in window names) + let sanitized_env_name = env_name_lower.replace('/', "-").replace('\\', "-"); + let window_name = format!("ushadow-{}", sanitized_env_name); eprintln!("[open_in_vscode] Creating tmux attach script for VS Code terminal"); @@ -855,7 +881,9 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result Result, base_branch: Option, _background: Option, + custom_window_name: Option, ) -> Result { // Force lowercase to avoid Docker Compose naming issues let name = name.to_lowercase(); + let branch_name = branch_name.map(|b| b.to_lowercase()); let base_branch = base_branch.map(|b| b.to_lowercase()); - eprintln!("[create_worktree_with_workmux] Creating worktree '{}' from branch '{:?}'", name, base_branch); + eprintln!("[create_worktree_with_workmux] Creating worktree '{}' with branch '{:?}' from base '{:?}'", name, branch_name, base_branch); - // Use the launcher's own worktree creation logic instead of workmux - // This ensures consistent directory structure + // Hybrid approach: Create worktree manually for custom control, then register with workmux + // Manual creation ensures: custom directory naming, ticket-based window names, lowercase enforcement + // Workmux registration adds: dashboard visibility, lifecycle tracking let main_repo_path = PathBuf::from(&main_repo); // Calculate worktrees directory: ../worktrees (sibling to project root) @@ -929,7 +961,7 @@ pub async fn create_worktree_with_workmux( eprintln!("[create_worktree_with_workmux] Worktrees directory: {}", worktrees_dir); // Create the worktree directly - let worktree = create_worktree(main_repo.clone(), worktrees_dir, name.clone(), base_branch).await?; + let worktree = create_worktree(main_repo.clone(), worktrees_dir, name.clone(), branch_name, base_branch).await?; eprintln!("[create_worktree_with_workmux] Worktree created at: {}", worktree.path); @@ -947,7 +979,14 @@ pub async fn create_worktree_with_workmux( } // Create tmux window for the worktree - let window_name = format!("ushadow-{}", name); + // Use custom window name if provided, otherwise generate from name + let window_name = if let Some(custom_name) = custom_window_name { + custom_name + } else { + // Sanitize name by replacing slashes and other special chars with dashes (tmux doesn't allow slashes in window names) + let sanitized_name = name.replace('/', "-").replace('\\', "-"); + format!("ushadow-{}", sanitized_name) + }; let create_window = shell_command(&format!( "tmux new-window -t workmux -n {} -c '{}'", window_name, worktree.path @@ -969,6 +1008,12 @@ pub async fn create_worktree_with_workmux( } } + // Note: We intentionally do NOT call `workmux open` here. + // `workmux open ` creates a window named `ushadow-{name}` (e.g. `ushadow-beige`), + // which would duplicate the ticket-specific window already created above (e.g. + // `ushadow-beige-act-dashboard-dev-ush-6`). The ticket window is the source of truth; + // workmux registration happens lazily when the user opens the terminal. + Ok(worktree) } @@ -1122,72 +1167,67 @@ pub async fn get_tmux_info() -> Result { /// Attach or create a tmux window for an existing worktree #[tauri::command] -pub async fn attach_tmux_to_worktree(worktree_path: String, env_name: String) -> Result { - let env_name = env_name.to_lowercase(); - let window_name = format!("ushadow-{}", env_name); +pub async fn attach_tmux_to_worktree( + worktree_path: String, + env_name: String, + window_name_override: Option +) -> Result { + eprintln!("[attach_tmux_to_worktree] Attaching to worktree at: {}", worktree_path); - // Ensure tmux is running - ensure_tmux_running().await?; + // Extract worktree name from path for workmux + let worktree_name = std::path::Path::new(&worktree_path) + .file_name() + .ok_or("Invalid worktree path")? + .to_str() + .ok_or("Path contains invalid UTF-8")?; - // Check if window already exists - let check_window = shell_command(&format!("tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", window_name)) - .output(); + eprintln!("[attach_tmux_to_worktree] Using workmux to open worktree: {}", worktree_name); - let window_existed = matches!(check_window, Ok(ref output) if output.status.success()); + // Use workmux open which handles everything: + // - Creates workmux session if needed + // - Creates window if needed + // - Reuses window if exists + // - Sets up working directory correctly + // - Registers in dashboard + let workmux_cmd = format!("cd '{}' && workmux open {}", worktree_path, worktree_name); - // Create window if it doesn't exist - if !window_existed { - let create_window = shell_command(&format!( - "tmux new-window -t workmux -n {} -c '{}'", - window_name, worktree_path - )) - .output() - .map_err(|e| format!("Failed to create tmux window: {}", e))?; + let open_result = shell_command(&workmux_cmd).output(); - if !create_window.status.success() { - let stderr = String::from_utf8_lossy(&create_window.stderr); - return Err(format!("Failed to create tmux window: {}", stderr)); + match open_result { + Ok(output) if output.status.success() => { + eprintln!("[attach_tmux_to_worktree] βœ“ Workmux window opened/reused"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[attach_tmux_to_worktree] Workmux open warning: {}", stderr); + } + Err(e) => { + return Err(format!("Failed to open workmux window: {}", e)); } } - // Open Terminal.app and attach to the tmux window + // Workmux has created/opened the window, now just focus iTerm #[cfg(target_os = "macos")] { - let script = format!( - "tell application \"Terminal\" to do script \"tmux attach-session -t workmux:{} && exit\"", - window_name - ); - - let open_terminal = Command::new("osascript") + let focus_script = r#"tell application "iTerm" to activate"#; + let _ = Command::new("osascript") .arg("-e") - .arg(&script) - .output() - .map_err(|e| format!("Failed to open Terminal: {}", e))?; + .arg(focus_script) + .output(); - if !open_terminal.status.success() { - let stderr = String::from_utf8_lossy(&open_terminal.stderr); - eprintln!("[attach_tmux_to_worktree] Warning: Failed to open Terminal.app: {}", stderr); - // Don't fail the whole operation if Terminal opening fails - } + eprintln!("[attach_tmux_to_worktree] Focused iTerm"); } #[cfg(not(target_os = "macos"))] { - // For Linux/Windows, try using default terminal - let _open_terminal = Command::new("x-terminal-emulator") - .arg("-e") - .arg(format!("tmux attach-session -t workmux:{}", window_name)) + // For Linux, try to focus terminal + let _ = Command::new("wmctrl") + .arg("-a") + .arg("tmux") .spawn(); - // Don't fail if this doesn't work } - let message = if window_existed { - format!("Opened tmux window '{}' (already existed)", window_name) - } else { - format!("Created and opened tmux window '{}' in {}", window_name, worktree_path) - }; - - Ok(message) + Ok(format!("Attached to worktree at {}", worktree_path)) } /// Get comprehensive tmux status for an environment @@ -1366,8 +1406,172 @@ pub async fn kill_tmux_server() -> Result { /// Open a tmux window in iTerm2 (falls back to Terminal.app if not available) #[tauri::command] -pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) -> Result { +pub async fn open_tmux_in_terminal( + window_name: String, + worktree_path: String, + environment_name: Option, +) -> Result { eprintln!("[open_tmux_in_terminal] Opening tmux window: {} at path: {}", window_name, worktree_path); + if let Some(ref env) = environment_name { + eprintln!("[open_tmux_in_terminal] Environment name: {}", env); + } + + // STEP 1: Use workmux to ensure window exists (idempotent) + // Extract worktree name from path (e.g., "gold" from "/Users/stu/repos/worktrees/ushadow/gold") + let worktree_name = std::path::Path::new(&worktree_path) + .file_name() + .ok_or("Invalid worktree path")? + .to_str() + .ok_or("Path contains invalid UTF-8")?; + + // Ensure the specific named window exists in the workmux session. + // We use direct tmux commands rather than `workmux open` to avoid creating a + // second window with the workmux naming convention (ushadow-{worktree_name}) + // alongside the ticket-specific window (ushadow-{branch}-{ticket_id}). + // Note: `tmux has-window` does not exist; use list-windows + grep instead. + let window_exists = shell_command(&format!( + "tmux list-windows -t workmux -F '#{{window_name}}' 2>/dev/null | grep -Fx '{}'", + window_name + )) + .output() + .map(|o| o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()) + .unwrap_or(false); + + if !window_exists { + eprintln!("[open_tmux_in_terminal] Window '{}' not found, creating it", window_name); + match shell_command(&format!( + "tmux new-window -t workmux -n {} -c '{}'", + window_name, worktree_path + )) + .output() + { + Ok(output) if output.status.success() => { + eprintln!("[open_tmux_in_terminal] βœ“ Created tmux window '{}'", window_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[open_tmux_in_terminal] Warning: Failed to create window: {}", stderr); + } + Err(e) => { + eprintln!("[open_tmux_in_terminal] Warning: Failed to create window: {}", e); + } + } + } else { + eprintln!("[open_tmux_in_terminal] βœ“ Window '{}' already exists", window_name); + } + + // The window name passed in is the authoritative name (ticket-specific or env-specific). + // We ensured it exists above, so use it directly. + let actual_window_name = window_name.clone(); + + // Pre-select the window in the tmux session. This makes `tmux attach-session -t workmux` + // (without a window spec) land on the right window. It also handles the case where + // multiple windows share a name β€” select-window picks the first match, and the attach + // script doesn't need to resolve the ambiguity. + let _ = shell_command(&format!( + "tmux select-window -t workmux:{}", + actual_window_name + )) + .output(); + eprintln!("[open_tmux_in_terminal] Selected window '{}'", actual_window_name); + + // Spawn agent start/resume in the background so it doesn't delay iTerm opening. + // check_and_resume_agent sleeps for several seconds waiting for Claude to load, + // so running it inline would make the button feel slow. + { + let window = actual_window_name.clone(); + let wpath = worktree_path.clone(); + tauri::async_runtime::spawn(async move { + let _ = check_and_resume_agent("workmux", &window, &wpath).await; + }); + } + + eprintln!("[open_tmux_in_terminal] Checking if window is already being viewed"); + + // Check if any client is currently viewing this specific window + // Use the format '#{client_session}:#{window_name}' to get accurate window info per client + let check_clients = shell_command(&format!( + "tmux list-clients -F '#{{client_session}}:#{{window_name}}' 2>/dev/null | grep -x 'workmux:{}'", + actual_window_name + )) + .output(); + + let has_clients = match &check_clients { + Ok(output) if output.status.success() => { + let clients = String::from_utf8_lossy(&output.stdout); + eprintln!("[open_tmux_in_terminal] Clients viewing window: {}", clients.trim()); + true + } + _ => { + eprintln!("[open_tmux_in_terminal] No clients viewing window"); + false + } + }; + + if has_clients { + eprintln!("[open_tmux_in_terminal] Window is already being viewed, checking if iTerm has visible windows"); + // Someone is already viewing this window, but check if iTerm actually has windows + #[cfg(target_os = "macos")] + { + // Check if iTerm has any windows + let count_windows = Command::new("osascript") + .arg("-e") + .arg(r#"tell application "iTerm" to count windows"#) + .output(); + + let has_iterm_windows = count_windows + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse::().ok()) + .map(|count| count > 0) + .unwrap_or(false); + + if !has_iterm_windows { + eprintln!("[open_tmux_in_terminal] Tmux clients exist but no iTerm windows - creating new window"); + // Fall through to create a new window + } else { + eprintln!("[open_tmux_in_terminal] iTerm has windows, attempting to focus"); + // First, activate iTerm + let focus_script = r#"tell application "iTerm" to activate"#; + let _ = Command::new("osascript") + .arg("-e") + .arg(focus_script) + .output(); + + // Give iTerm time to activate + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Try to select the window that's attached to this tmux session + let select_script = format!( + r#"tell application "iTerm" + repeat with aWindow in windows + try + tell current session of aWindow + if name contains "{}" then + select aWindow + return + end if + end tell + end try + end repeat +end tell"#, + actual_window_name + ); + let _ = Command::new("osascript") + .arg("-e") + .arg(&select_script) + .output(); + + return Ok(format!("Focused iTerm window for '{}'", actual_window_name)); + } + } + #[cfg(not(target_os = "macos"))] + { + return Ok(format!("Tmux window '{}' is already open", actual_window_name)); + } + } + + eprintln!("[open_tmux_in_terminal] No clients viewing this window, will open new terminal"); #[cfg(target_os = "macos")] { @@ -1384,8 +1588,11 @@ pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) - if iterm_available { eprintln!("[open_tmux_in_terminal] Using iTerm2"); - // Extract env name from window name (format: "ushadow-{env}") - let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + // Use provided environment name for display and coloring + // Fall back to extracting from window name if not provided + let env_name = environment_name + .as_deref() + .unwrap_or_else(|| window_name.strip_prefix("ushadow-").unwrap_or(&window_name)); let display_name = format!("Ushadow: {}", env_name); // Map env names to RGB colors (0-255) @@ -1406,10 +1613,9 @@ pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) - let temp_script = format!("/tmp/ushadow_iterm_{}.sh", window_name.replace("/", "_")); let script_content = format!( - "#!/bin/bash\nprintf '\\033]0;{}\\007\\033]6;1;bg;red;brightness;{}\\007\\033]6;1;bg;green;brightness;{}\\007\\033]6;1;bg;blue;brightness;{}\\007'\n# Attach to the workmux session and select the specific window\nexec tmux attach-session -t workmux:{}\n", + "#!/bin/bash\nprintf '\\033]0;{}\\007\\033]6;1;bg;red;brightness;{}\\007\\033]6;1;bg;green;brightness;{}\\007\\033]6;1;bg;blue;brightness;{}\\007'\n# Attach to workmux session - window was pre-selected by the launcher\nexec tmux attach-session -t workmux\n", display_name, r, g, b, - window_name ); fs::write(&temp_script, script_content) .map_err(|e| format!("Failed to write temp script: {}", e))?; @@ -1418,38 +1624,17 @@ pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) - .output() .map_err(|e| format!("Failed to chmod: {}", e))?; - // iTerm2 AppleScript that reuses existing windows with matching name + // iTerm2 AppleScript - simplified to just create a new window + // The check above already ensures we don't create duplicates let applescript = format!( r#"tell application "iTerm" activate - - -- Try to find existing window with this name - set foundWindow to false - repeat with aWindow in windows - repeat with aTab in tabs of aWindow - repeat with aSession in sessions of aTab - if name of aSession is "{}" then - -- Found existing window, select it - select aSession - set foundWindow to true - exit repeat - end if - end repeat - if foundWindow then exit repeat - end repeat - if foundWindow then exit repeat - end repeat - - -- If no existing window found, create new one - if not foundWindow then - set newWindow to (create window with default profile) - tell current session of newWindow - set name to "{}" - write text "{} && exit" - end tell - end if + set newWindow to (create window with default profile) + tell current session of newWindow + set name to "{}" + write text "{} && exit" + end tell end tell"#, - display_name, display_name, temp_script ); @@ -1462,7 +1647,7 @@ end tell"#, if output.status.success() { eprintln!("[open_tmux_in_terminal] iTerm2 success"); - return Ok(format!("Opened tmux window '{}' in iTerm2", window_name)); + return Ok(format!("Opened tmux window '{}' in iTerm2", actual_window_name)); } else { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("[open_tmux_in_terminal] iTerm2 failed: {}, falling back to Terminal.app", stderr); @@ -1472,19 +1657,17 @@ end tell"#, } // Fallback to Terminal.app (macOS default terminal) - let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + let env_name = environment_name + .as_deref() + .unwrap_or_else(|| actual_window_name.strip_prefix("ushadow-").unwrap_or(&actual_window_name)); let display_name = format!("Ushadow: {}", env_name); // Create temp script for Terminal.app (simpler than iTerm2, no tab colors) use std::fs; - let temp_script = format!("/tmp/ushadow_terminal_{}.sh", window_name.replace("/", "_")); + let temp_script = format!("/tmp/ushadow_terminal_{}.sh", actual_window_name.replace("/", "_")); let script_content = format!( - "#!/bin/bash\nprintf '\\033]0;{}\\007'\n# Create dedicated session for this environment if it doesn't exist\ntmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'\n# Attach to this environment's dedicated session\nexec tmux attach-session -t {}\n", - display_name, - window_name, - window_name, - worktree_path, - window_name + "#!/bin/bash\nprintf '\\033]0;{}\\007'\n# Attach to workmux session - window was pre-selected by the launcher\nexec tmux attach-session -t workmux\n", + display_name ); fs::write(&temp_script, script_content) .map_err(|e| format!("Failed to write temp script: {}", e))?; @@ -1513,7 +1696,7 @@ end tell"#, } eprintln!("[open_tmux_in_terminal] Terminal.app success"); - Ok(format!("Opened tmux window '{}' in Terminal.app", window_name)) + Ok(format!("Opened tmux window '{}' in Terminal.app", actual_window_name)) } #[cfg(not(target_os = "macos"))] @@ -1551,6 +1734,91 @@ end tell"#, } } +/// Check if Claude agent is running in a tmux window; start or resume it if not. +/// +/// Always tries `claude --resume` first so the user gets their last conversation back. +/// If Claude starts fresh (no prior session), and there's a ticket for this worktree, +/// sends the ticket title + description as initial context. +pub async fn check_and_resume_agent( + tmux_session_name: &str, + tmux_window_name: &str, + worktree_path: &str, +) -> Result { + use super::kanban::get_ticket_by_worktree_path; + + eprintln!("[check_and_resume_agent] Checking agent status for window {}", tmux_window_name); + + // 1. Check the current foreground process in the pane β€” this is reliable because + // Claude's startup banner stays in the scrollback after it exits, so scanning + // pane text gives false positives. + let current_command = shell_command(&format!( + "tmux display-message -t {}:{} -p '#{{pane_current_command}}'", + tmux_session_name, tmux_window_name + )) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + eprintln!("[check_and_resume_agent] Current pane command: '{}'", current_command); + + // Shells indicate Claude is not running; anything else (claude, node, python…) is. + let is_shell = matches!(current_command.as_str(), "zsh" | "bash" | "sh" | "fish" | ""); + if !is_shell { + eprintln!("[check_and_resume_agent] Agent already running ({}), no action needed", current_command); + return Ok(false); + } + + // 2. Check if Claude has prior session files for this worktree. + // If yes, resume β€” the user gets their conversation back. + // If no, start fresh and pass the ticket context as a CLI argument + // so there's no timing dependency or risk of text landing on the wrong prompt. + let home_dir = std::env::var("HOME").unwrap_or_default(); + let encoded_path = worktree_path.replace('/', "-"); + let sessions_dir = format!("{}/.claude/projects/{}", home_dir, encoded_path); + let has_sessions = std::fs::read_dir(&sessions_dir) + .map(|entries| entries.filter_map(|e| e.ok()).any(|e| { + e.path().extension().map(|x| x == "jsonl").unwrap_or(false) + })) + .unwrap_or(false); + + let claude_cmd = if has_sessions { + eprintln!("[check_and_resume_agent] Session files found β€” resuming"); + "claude --resume --dangerously-skip-permissions".to_string() + } else { + // Fresh start: look up the ticket and embed context directly in the command. + // claude "initial message" starts an interactive session pre-loaded with that message. + let ticket = get_ticket_by_worktree_path(worktree_path); + if let Some(ticket) = ticket { + eprintln!("[check_and_resume_agent] No sessions β€” starting fresh with ticket context: {}", ticket.title); + let prompt = format!( + "You are working on the following ticket:\n\nTitle: {}\n\nDescription: {}\n\nPlease help implement this feature.", + ticket.title, + ticket.description.as_ref().unwrap_or(&"No description".to_string()) + ); + // Single-quote escape for the outer shell command + let escaped = prompt.replace('\'', "'\\''"); + format!("claude --dangerously-skip-permissions '{}'", escaped) + } else { + eprintln!("[check_and_resume_agent] No sessions, no ticket β€” starting plain Claude"); + "claude --dangerously-skip-permissions".to_string() + } + }; + + eprintln!("[check_and_resume_agent] Running: {}", claude_cmd); + let result = shell_command(&format!( + "tmux send-keys -t {}:{} '{}' Enter", + tmux_session_name, tmux_window_name, claude_cmd + )) + .output(); + + if let Err(e) = result { + eprintln!("[check_and_resume_agent] Failed to start Claude: {}", e); + return Ok(false); + } + + Ok(true) +} + /// Capture the visible content of a tmux pane #[tauri::command] pub async fn capture_tmux_pane(window_name: String) -> Result { diff --git a/ushadow/launcher/src-tauri/tauri.conf.json b/ushadow/launcher/src-tauri/tauri.conf.json index 5b01c193..b9d7f05a 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Ushadow", - "version": "0.8.0" + "version": "0.8.1" }, "tauri": { "allowlist": { @@ -132,7 +132,8 @@ "minWidth": 800, "minHeight": 600, "center": true, - "visible": false + "visible": false, + "fileDropEnabled": false } ] } diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index 0c0d6692..9d085118 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -71,7 +71,7 @@ function App() { const [leftColumnWidth, setLeftColumnWidth] = useState(350) // pixels const [isResizing, setIsResizing] = useState(false) const [environmentConflict, setEnvironmentConflict] = useState(null) - const [pendingEnvCreation, setPendingEnvCreation] = useState<{ name: string; branch: string } | null>(null) + const [pendingEnvCreation, setPendingEnvCreation] = useState<{ name: string; branch: string; baseBranch?: string } | null>(null) const [selectedEnvironment, setSelectedEnvironment] = useState(null) // Auto-select environment matching current directory's ENV_NAME, or first running @@ -947,9 +947,10 @@ function App() { log(`Creating worktree "${name}" from branch "${branch}"...`, 'step') log(`Project root: ${effectiveProjectRoot}`, 'info') log(`Worktrees dir: ${effectiveWorktreesDir}`, 'info') + log(`Base branch: ${baseBranch || 'auto-detect from suffix'}`, 'info') try { - const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch, true) + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch, baseBranch, true, undefined) log(`βœ“ Worktree created successfully`, 'success') log(`Path: ${worktree.path}`, 'info') log(`Branch: ${worktree.branch}`, 'info') @@ -980,12 +981,13 @@ function App() { } } - const handleNewEnvWorktree = async (name: string, branch: string) => { + const handleNewEnvWorktree = async (name: string, branch: string, baseBranch?: string) => { setShowNewEnvDialog(false) // Force lowercase to avoid Docker Compose naming issues name = name.toLowerCase() branch = branch.toLowerCase() + baseBranch = baseBranch?.toLowerCase() if (!effectiveWorktreesDir) { log('Worktrees directory not configured', 'error') @@ -1002,7 +1004,7 @@ function App() { // Show conflict dialog setEnvironmentConflict(conflict) - setPendingEnvCreation({ name, branch }) + setPendingEnvCreation({ name, branch, baseBranch }) return } } catch (err) { @@ -1025,7 +1027,7 @@ function App() { } else { // Step 1: Create the git worktree with workmux (includes tmux integration) log(`Creating git worktree at ${envPath}...`, 'info') - const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, true) + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, baseBranch, true, undefined) log(`βœ“ Worktree created at ${worktree.path}`, 'success') // Step 1.5: Write default admin credentials if configured @@ -1098,7 +1100,7 @@ function App() { const handleConflictSwitchBranch = async () => { if (!environmentConflict || !pendingEnvCreation) return - const { name, branch } = pendingEnvCreation + const { name, branch, baseBranch } = pendingEnvCreation setEnvironmentConflict(null) setPendingEnvCreation(null) @@ -1126,7 +1128,7 @@ function App() { const handleConflictDeleteAndRecreate = async () => { if (!environmentConflict || !pendingEnvCreation) return - const { name, branch } = pendingEnvCreation + const { name, branch, baseBranch } = pendingEnvCreation setEnvironmentConflict(null) setPendingEnvCreation(null) @@ -1152,7 +1154,7 @@ function App() { log(`[DRY RUN] Worktree environment "${name}" created`, 'success') } else { log(`Creating git worktree at ${envPath}...`, 'info') - const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, true) + const worktree = await tauri.createWorktreeWithWorkmux(effectiveProjectRoot, name, branch || undefined, baseBranch, true, undefined) log(`βœ“ Worktree created at ${worktree.path}`, 'success') // Write credentials if configured diff --git a/ushadow/launcher/src/components/KanbanBoard.tsx b/ushadow/launcher/src/components/KanbanBoard.tsx index 2d187a9f..c3f611a0 100644 --- a/ushadow/launcher/src/components/KanbanBoard.tsx +++ b/ushadow/launcher/src/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Plus, Tag, Folder } from 'lucide-react' import { TicketCard } from './TicketCard' import { CreateTicketDialog } from './CreateTicketDialog' @@ -61,6 +61,9 @@ export function KanbanBoard({ projectId, backendUrl, projectRoot }: KanbanBoardP const [showCreateTicket, setShowCreateTicket] = useState(false) const [showCreateEpic, setShowCreateEpic] = useState(false) const [selectedEpic, setSelectedEpic] = useState(null) + const [dragOverColumn, setDragOverColumn] = useState(null) + // Keep a ref to tickets so handleDrop always sees the latest values + const ticketsRef = useRef([]) // Fetch tickets and epics (using Tauri commands) const fetchData = useCallback(async (isInitialLoad = false) => { @@ -78,6 +81,7 @@ export function KanbanBoard({ projectId, backendUrl, projectRoot }: KanbanBoardP ]) setTickets(ticketsData) + ticketsRef.current = ticketsData setEpics(epicsData) } catch (error) { console.error('Failed to fetch kanban data:', error) @@ -109,6 +113,49 @@ export function KanbanBoard({ projectId, backendUrl, projectRoot }: KanbanBoardP }, {} as Record) : ticketsByStatus + const handleDrop = useCallback(async (e: React.DragEvent, targetStatus: TicketStatus) => { + e.preventDefault() + setDragOverColumn(null) + + const ticketId = e.dataTransfer.getData('text/plain') + const ticket = ticketsRef.current.find(t => t.id === ticketId) + if (!ticket || ticket.status === targetStatus) return + + try { + const { tauri } = await import('../hooks/useTauri') + + // Stop agent when moving to todo: kill tmux window + if (targetStatus === 'todo' && ticket.tmux_window_name) { + try { + await tauri.killTmuxWindow(ticket.tmux_window_name) + } catch (err) { + console.error('[KanbanBoard] Failed to kill tmux window:', err) + } + } + + // Unassign when moving to backlog or done + const shouldUnassign = targetStatus === 'backlog' || targetStatus === 'done' + // For todo: only clear tmux fields + const shouldClearTmux = targetStatus === 'todo' + + await tauri.updateTicket( + ticketId, + undefined, undefined, + targetStatus, + undefined, undefined, undefined, undefined, + shouldUnassign ? '' : undefined, // worktreePath + shouldUnassign ? '' : undefined, // branchName + shouldUnassign || shouldClearTmux ? '' : undefined, // tmuxWindowName + shouldUnassign || shouldClearTmux ? '' : undefined, // tmuxSessionName + shouldUnassign ? '' : undefined, // environmentName + ) + + fetchData(false) + } catch (err) { + console.error('[KanbanBoard] Failed to move ticket:', err) + } + }, [fetchData]) + const handleTicketCreated = () => { fetchData(false) // Background refresh after creation setShowCreateTicket(false) @@ -179,7 +226,18 @@ export function KanbanBoard({ projectId, backendUrl, projectRoot }: KanbanBoardP {COLUMNS.map((column) => (
{ e.preventDefault(); setDragOverColumn(column.status) }} + onDragEnter={(e) => { e.preventDefault(); setDragOverColumn(column.status) }} + onDragLeave={(e) => { + // Only clear if leaving the column entirely (not entering a child) + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverColumn(null) + } + }} + onDrop={(e) => handleDrop(e, column.status)} data-testid={`kanban-column-${column.status}`} > {/* Column Header */} @@ -193,7 +251,7 @@ export function KanbanBoard({ projectId, backendUrl, projectRoot }: KanbanBoardP
{/* Ticket List */} -
+
e.preventDefault()}> {filteredTickets[column.status]?.map((ticket) => ( void onLink: (name: string, path: string) => void - onWorktree: (name: string, branch: string) => void + onWorktree: (name: string, branch: string, baseBranch?: string) => void } +type BaseType = 'main' | 'dev' | 'worktree' + export function NewEnvironmentDialog({ isOpen, projectRoot, @@ -17,37 +20,79 @@ export function NewEnvironmentDialog({ }: NewEnvironmentDialogProps) { const [name, setName] = useState('') const [branch, setBranch] = useState('') - const [baseBranch, setBaseBranch] = useState<'main' | 'dev'>('main') + const [baseType, setBaseType] = useState('main') + const [selectedWorktree, setSelectedWorktree] = useState('') + const [environments, setEnvironments] = useState([]) + const [loadingEnvs, setLoadingEnvs] = useState(false) - // Reset form when dialog closes + // Load environments when dialog opens useEffect(() => { - if (!isOpen) { + if (isOpen) { + loadEnvironments() + } else { + // Reset form when dialog closes setName('') setBranch('') - setBaseBranch('main') + setBaseType('main') + setSelectedWorktree('') } }, [isOpen]) + const loadEnvironments = async () => { + setLoadingEnvs(true) + try { + const { tauri } = await import('../hooks/useTauri') + const discovery = await tauri.discoverEnvironments() + // Only show worktree environments that have a branch + const worktreeEnvs = discovery.environments.filter((env) => env.is_worktree && env.branch) + setEnvironments(worktreeEnvs) + if (worktreeEnvs.length > 0 && !selectedWorktree) { + setSelectedWorktree(worktreeEnvs[0].branch!) + } + } catch (err) { + console.error('Failed to load environments:', err) + } finally { + setLoadingEnvs(false) + } + } + if (!isOpen) return null const handleSubmit = () => { if (!name.trim()) return - // Branch name format: envname/branchname-basebranch - // Example: rouge/myfeature-dev or staging/hotfix-main + if (baseType === 'worktree' && !selectedWorktree) return + const envName = name.trim() const branchSuffix = branch.trim() || 'base' - const branchName = `${envName}/${branchSuffix}-${baseBranch}` - onWorktree(envName, branchName) + + // Branch name format: envname/branchname-basebranch (if main/dev) + // or: envname/branchname (if branching from another worktree) + let branchName: string + let baseBranch: string | undefined + + if (baseType === 'worktree') { + branchName = `${envName}/${branchSuffix}` + baseBranch = selectedWorktree // Full branch name like "rouge/feature-dev" + } else { + branchName = `${envName}/${branchSuffix}-${baseType}` + baseBranch = baseType // 'main' or 'dev' + } + + onWorktree(envName, branchName, baseBranch) } - const isValid = name.trim() + const isValid = name.trim() && (baseType !== 'worktree' || selectedWorktree) return (
-
+
e.stopPropagation()} + > {/* Header */}

@@ -103,25 +148,31 @@ export function NewEnvironmentDialog({ data-testid="branch-name-input" />

- {branch.trim() && name.trim() - ? `Will create: ${name.trim()}/${branch.trim()}-${baseBranch}` - : name.trim() - ? `Will create: ${name.trim()}/base-${baseBranch}` - : `Enter environment name first`} + {(() => { + const envName = name.trim() + const branchSuffix = branch.trim() || 'base' + if (!envName) return 'Enter environment name first' + + if (baseType === 'worktree') { + return `Will create: ${envName}/${branchSuffix} from ${selectedWorktree || '(select worktree)'}` + } else { + return `Will create: ${envName}/${branchSuffix}-${baseType} from origin/${baseType}` + } + })()}

{/* Base Branch Selection */}
-
+
+
-

- Creates worktree from origin/{baseBranch} + + {/* Worktree selection dropdown */} + {baseType === 'worktree' && ( +

+ + {loadingEnvs ? ( +
+ Loading worktrees... +
+ ) : environments.length === 0 ? ( +
+ No worktrees available +
+ ) : ( + + )} +
+ )} + +

+ {baseType === 'worktree' + ? `Creates worktree branching from selected worktree` + : `Creates worktree from origin/${baseType}`}

{/* Helper text */}

- Creates a git worktree with branch name: envname/branchname-{baseBranch} from origin/{baseBranch} + {baseType === 'worktree' + ? 'Creates a git worktree branching from the selected worktree' + : `Creates a git worktree with branch name: envname/branchname-${baseType} from origin/${baseType}`}

{/* Actions */} diff --git a/ushadow/launcher/src/components/TicketDetailDialog.tsx b/ushadow/launcher/src/components/TicketDetailDialog.tsx index cb1e669c..429c1e0a 100644 --- a/ushadow/launcher/src/components/TicketDetailDialog.tsx +++ b/ushadow/launcher/src/components/TicketDetailDialog.tsx @@ -9,9 +9,13 @@ import { Plus, AlertCircle, CheckCircle, + Code2, + Loader2, } from 'lucide-react' import type { Ticket, Epic, UshadowEnvironment } from '../hooks/useTauri' import { EnvironmentBadge } from './EnvironmentBadge' +import { NewEnvironmentDialog } from './NewEnvironmentDialog' +import { getColors } from '../utils/colors' interface TicketDetailDialogProps { ticket: Ticket @@ -24,10 +28,10 @@ interface TicketDetailDialogProps { } const PRIORITY_OPTIONS = [ - { value: 'low', label: 'Low', color: 'bg-gray-600' }, - { value: 'medium', label: 'Medium', color: 'bg-blue-600' }, - { value: 'high', label: 'High', color: 'bg-orange-600' }, - { value: 'urgent', label: 'Urgent', color: 'bg-red-600' }, + { value: 'low', label: 'Low', color: 'bg-surface-500', textColor: 'text-text-secondary', icon: 'β–Ό' }, + { value: 'medium', label: 'Medium', color: 'bg-info-500', textColor: 'text-white', icon: 'β– ' }, + { value: 'high', label: 'High', color: 'bg-warning-500', textColor: 'text-surface-900', icon: 'β–²' }, + { value: 'urgent', label: 'Urgent', color: 'bg-error-500', textColor: 'text-white', icon: '⚠' }, ] const STATUS_OPTIONS = [ @@ -62,6 +66,12 @@ export function TicketDetailDialog({ const [creatingWorktree, setCreatingWorktree] = useState(false) const [assigningEnv, setAssigningEnv] = useState(false) const [successMessage, setSuccessMessage] = useState(null) + const [showNewEnvDialog, setShowNewEnvDialog] = useState(false) + const [showPriorityMenu, setShowPriorityMenu] = useState(false) + const [showWorkstreamMenu, setShowWorkstreamMenu] = useState(false) + const [showRecreateDialog, setShowRecreateDialog] = useState(false) + const [recreatingTmux, setRecreatingTmux] = useState(false) + const [openingTerminal, setOpeningTerminal] = useState(false) // Load environments on mount useEffect(() => { @@ -70,6 +80,33 @@ export function TicketDetailDialog({ } }, [isOpen]) + // Close modal on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + if (isOpen) { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, onClose]) + + // Close dropdowns when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (!target.closest('[data-dropdown]')) { + setShowPriorityMenu(false) + setShowWorkstreamMenu(false) + } + } + + if (showPriorityMenu || showWorkstreamMenu) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [showPriorityMenu, showWorkstreamMenu]) + const loadEnvironments = async () => { setLoadingEnvs(true) try { @@ -90,15 +127,37 @@ export function TicketDetailDialog({ try { const { tauri } = await import('../hooks/useTauri') + const statusChanged = status !== ticket.status + + // Stop agent when moving to todo: kill the tmux window + if (statusChanged && status === 'todo' && ticket.tmux_window_name) { + try { + await tauri.killTmuxWindow(ticket.tmux_window_name) + } catch (err) { + console.error('[TicketDetail] Failed to kill tmux window:', err) + // Non-fatal: continue with status update + } + } + + // Unassign when moving to backlog or done: clear all assignment fields + const shouldUnassign = statusChanged && (status === 'backlog' || status === 'done') + // For todo: only clear tmux fields (keep worktree/branch/env for reference) + const shouldClearTmux = statusChanged && status === 'todo' + await tauri.updateTicket( ticket.id, title !== ticket.title ? title : undefined, description !== ticket.description ? description : undefined, - status !== ticket.status ? status : undefined, + statusChanged ? status : undefined, priority !== ticket.priority ? priority : undefined, epicId !== ticket.epic_id ? epicId || undefined : undefined, JSON.stringify(tags) !== JSON.stringify(ticket.tags) ? tags : undefined, - undefined // order + undefined, // order + shouldUnassign ? '' : undefined, // worktreePath + shouldUnassign ? '' : undefined, // branchName + shouldUnassign || shouldClearTmux ? '' : undefined, // tmuxWindowName + shouldUnassign || shouldClearTmux ? '' : undefined, // tmuxSessionName + shouldUnassign ? '' : undefined, // environmentName ) setSuccessMessage('Ticket updated successfully') @@ -122,27 +181,32 @@ export function TicketDetailDialog({ setTags(tags.filter((t) => t !== tagToRemove)) } - const handleCreateWorktree = async () => { + const handleCreateWorktree = async (envName: string, branchName: string, baseBranch?: string) => { setError(null) setCreatingWorktree(true) + setShowNewEnvDialog(false) try { const { tauri } = await import('../hooks/useTauri') - // Get epic for base branch if ticket has one - const epic = epicId ? epics.find((e) => e.id === epicId) : null - + // Note: When user provides a branch via dialog, we don't use epic's shared branch + // The branchName from dialog is in format "envname/feature-basebranch" (for main/dev) + // or "envname/feature" (when branching from another worktree) const request = { - ticketId: ticket.id, - ticketTitle: ticket.title, - projectRoot, - branchName: undefined, // Will be auto-generated - baseBranch: epic?.base_branch || 'main', - epicBranch: epic?.branch_name || undefined, + ticket_id: ticket.id, + ticket_title: ticket.title, + project_root: projectRoot, + environment_name: envName, // Simple name for worktree directory (e.g., "staging") + branch_name: branchName, // Full branch name (e.g., "staging/feature-main") + base_branch: baseBranch, // Use the base branch from dialog (main/dev/worktree branch) + epic_branch: undefined, // Not using epic branch when user provides custom branch } const result = await tauri.createTicketWorktree(request) + // Extract environment name from branch name (format: "envname/feature") + const extractedEnvName = result.branch_name.split('/')[0] + // Update ticket with worktree info await tauri.updateTicket( ticket.id, @@ -156,7 +220,8 @@ export function TicketDetailDialog({ result.worktree_path, // worktreePath result.branch_name, // branchName result.tmux_window_name, // tmuxWindowName - result.tmux_session_name // tmuxSessionName + result.tmux_session_name, // tmuxSessionName + extractedEnvName // environmentName - extract from branch name ) // Start coding agent in the tmux window @@ -201,6 +266,9 @@ export function TicketDetailDialog({ const result = await tauri.attachTicketToWorktree(ticket.id, env.path, env.branch) + // Extract environment name from branch name (format: "envname/feature") + const extractedEnvName = result.branch_name.split('/')[0] + // Update ticket with environment info await tauri.updateTicket( ticket.id, @@ -215,7 +283,7 @@ export function TicketDetailDialog({ result.branch_name, // branchName result.tmux_window_name, // tmuxWindowName result.tmux_session_name, // tmuxSessionName - env.name // environmentName - save the environment name! + extractedEnvName // environmentName - extract from branch name ) // Start coding agent in the tmux window @@ -242,57 +310,105 @@ export function TicketDetailDialog({ } } + const handleRecreateTmuxWindow = async () => { + if (!ticket.worktree_path || !ticket.environment_name) { + setError('Missing worktree path or environment name') + return + } + + setError(null) + setRecreatingTmux(true) + setShowRecreateDialog(false) + + try { + const { tauri } = await import('../hooks/useTauri') + + // Recreate the tmux window using attach command + // Pass the stored tmux_window_name to ensure we create/find the correct window + await tauri.attachTmuxToWorktree( + ticket.worktree_path, + ticket.environment_name, + ticket.tmux_window_name || undefined + ) + + setSuccessMessage('Tmux window recreated successfully') + setTimeout(() => setSuccessMessage(null), 3000) + + // Now try to open the terminal + if (ticket.tmux_window_name) { + await tauri.openTmuxInTerminal( + ticket.tmux_window_name, + ticket.worktree_path, + ticket.environment_name + ) + } + } catch (err) { + console.error('[TicketDetail] Error recreating tmux window:', err) + setError(err instanceof Error ? err.message : 'Failed to recreate tmux window') + } finally { + setRecreatingTmux(false) + } + } + + const handleClearAssignment = async () => { + setShowRecreateDialog(false) + + try { + const { tauri } = await import('../hooks/useTauri') + await tauri.updateTicket( + ticket.id, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + '', '', '', '', '' + ) + setSuccessMessage('Environment assignment cleared') + setTimeout(() => setSuccessMessage(null), 3000) + onUpdated() + } catch (clearErr) { + setError(clearErr instanceof Error ? clearErr.message : 'Failed to clear assignment') + } + } + if (!isOpen) return null const selectedEpic = epicId ? epics.find((e) => e.id === epicId) : null const hasAssignment = ticket.worktree_path || ticket.branch_name + const selectedPriority = PRIORITY_OPTIONS.find(opt => opt.value === priority) + return (
e.stopPropagation()} > {/* Header */} -
-

+
+

Ticket Details

-
+
{/* Success Message */} {successMessage && (
- +
-

+

{successMessage}

@@ -302,16 +418,12 @@ export function TicketDetailDialog({ {/* Error Message */} {error && (
- +
-

+

{error}

@@ -319,96 +431,70 @@ export function TicketDetailDialog({ )} {/* Two Column Layout */} -
- {/* Left Column - Basic Info */} -
-

- Basic Information -

- +
+ {/* Left Column - Content */} +
{/* Title */}
-
- {/* Description */} -
- -