From 2e09e37bf71a570ced3662b52d28f094bb15b00a Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Tue, 17 Feb 2026 14:03:08 +0000 Subject: [PATCH 1/4] kanban ticket move and workmux --- .tmux.conf | 11 +- .workmux.yaml | 2 + scripts/register-one-worktree.sh | 37 + scripts/register-worktrees-with-workmux.sh | 60 ++ .../backend/src/config/keycloak_settings.py | 2 +- .../.claude/hooks/idle-notification.sh | 13 +- ushadow/launcher/.claude/hooks/session-end.sh | 9 +- .../launcher/.claude/hooks/session-start.sh | 9 +- .../.claude/hooks/user-prompt-submit.sh | 9 +- ushadow/launcher/.claude/settings.local.json | 51 +- ushadow/launcher/dist/index.html | 4 +- .../launcher/src-tauri/src/commands/kanban.rs | 151 ++- .../src-tauri/src/commands/worktree.rs | 487 +++++++-- ushadow/launcher/src-tauri/tauri.conf.json | 3 +- ushadow/launcher/src/App.tsx | 18 +- .../launcher/src/components/KanbanBoard.tsx | 64 +- .../src/components/NewEnvironmentDialog.tsx | 153 ++- .../src/components/TicketDetailDialog.tsx | 922 ++++++++++-------- ushadow/launcher/src/hooks/useTauri.ts | 25 +- 19 files changed, 1403 insertions(+), 627 deletions(-) create mode 100755 scripts/register-one-worktree.sh create mode 100755 scripts/register-worktrees-with-workmux.sh 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 33966f3f..17623c42 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:KEYCLOAK_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/src-tauri/src/commands/kanban.rs b/ushadow/launcher/src-tauri/src/commands/kanban.rs index 55cfb13c..d14bfa05 100644 --- a/ushadow/launcher/src-tauri/src/commands/kanban.rs +++ b/ushadow/launcher/src-tauri/src/commands/kanban.rs @@ -12,6 +12,7 @@ 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,22 +51,25 @@ 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); @@ -93,11 +97,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 +150,65 @@ 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); Ok(CreateTicketWorktreeResult { worktree_path, - branch_name, + branch_name: actual_branch, tmux_window_name, tmux_session_name, }) @@ -567,20 +625,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(); diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index b5f43b6e..e6d9163e 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,28 @@ pub async fn create_worktree_with_workmux( } } + // Register with workmux for dashboard visibility (hybrid approach) + // By default, workmux open doesn't run hooks or file operations, so it's safe + eprintln!("[create_worktree_with_workmux] Registering worktree with workmux for dashboard visibility"); + let register_cmd = format!( + "cd '{}' && workmux open {}", + worktree.path, name + ); + match shell_command(®ister_cmd).output() { + Ok(output) if output.status.success() => { + eprintln!("[create_worktree_with_workmux] βœ“ Worktree registered with workmux dashboard"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[create_worktree_with_workmux] Warning: Failed to register with workmux: {}", stderr); + // Don't fail the whole operation if workmux registration fails + } + Err(e) => { + eprintln!("[create_worktree_with_workmux] Warning: Failed to register with workmux: {}", e); + // Don't fail the whole operation if workmux registration fails + } + } + Ok(worktree) } @@ -1122,72 +1183,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 +1422,159 @@ 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")?; + + eprintln!("[open_tmux_in_terminal] Using workmux to ensure window exists for worktree: {}", worktree_name); + let workmux_cmd = format!( + "cd '{}' && workmux open {}", + worktree_path, + worktree_name + ); + + let open_result = shell_command(&workmux_cmd).output(); + + match open_result { + Ok(output) if output.status.success() => { + eprintln!("[open_tmux_in_terminal] βœ“ Workmux window ready"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[open_tmux_in_terminal] Workmux open warning: {}", stderr); + // Don't fail - window might still exist from before + } + Err(e) => { + eprintln!("[open_tmux_in_terminal] Workmux open error: {}", e); + // Don't fail - window might still exist from before + } + } + + eprintln!("[open_tmux_in_terminal] Checking if window is already being viewed"); + + // After workmux open, find the actual window being used for this worktree + // Workmux might create a window with a different name than what we expect + let find_window = shell_command(&format!( + "tmux list-windows -t workmux -F '#{{window_name}}:#{{pane_current_path}}' 2>/dev/null | grep '{}$' | cut -d: -f1 | head -1", + worktree_path + )) + .output(); + + let actual_window_name = match find_window { + Ok(output) if output.status.success() => { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + eprintln!("[open_tmux_in_terminal] Found window '{}' for path {}", name, worktree_path); + name + } else { + eprintln!("[open_tmux_in_terminal] No window found for path, using provided name"); + window_name.clone() + } + } + _ => { + eprintln!("[open_tmux_in_terminal] Failed to query windows, using provided name"); + window_name.clone() + } + }; + + // 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 +1591,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) @@ -1409,7 +1619,7 @@ pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) - "#!/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", display_name, r, g, b, - window_name + actual_window_name ); fs::write(&temp_script, script_content) .map_err(|e| format!("Failed to write temp script: {}", e))?; @@ -1418,38 +1628,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 +1651,19 @@ end tell"#, if output.status.success() { eprintln!("[open_tmux_in_terminal] iTerm2 success"); - return Ok(format!("Opened tmux window '{}' in iTerm2", window_name)); + + // STEP 5: Check and resume agent if not running + let resumed = check_and_resume_agent( + "workmux", + &actual_window_name, + &worktree_path + ).await.unwrap_or(false); + + if resumed { + return Ok(format!("Opened window '{}' in iTerm2 and resumed agent", actual_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 +1673,18 @@ 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", + "#!/bin/bash\nprintf '\\033]0;{}\\007'\n# Attach to the workmux session and select the specific window\nexec tmux attach-session -t workmux:{}\n", display_name, - window_name, - window_name, - worktree_path, - window_name + actual_window_name ); fs::write(&temp_script, script_content) .map_err(|e| format!("Failed to write temp script: {}", e))?; @@ -1513,7 +1713,19 @@ end tell"#, } eprintln!("[open_tmux_in_terminal] Terminal.app success"); - Ok(format!("Opened tmux window '{}' in Terminal.app", window_name)) + + // Check and resume agent if not running + let resumed = check_and_resume_agent( + "workmux", + &actual_window_name, + &worktree_path + ).await.unwrap_or(false); + + if resumed { + Ok(format!("Opened window '{}' in Terminal.app and resumed agent", actual_window_name)) + } else { + Ok(format!("Opened tmux window '{}' in Terminal.app", actual_window_name)) + } } #[cfg(not(target_os = "macos"))] @@ -1551,6 +1763,73 @@ end tell"#, } } +/// Check if Claude agent is running in a tmux window, and resume if needed +pub async fn check_and_resume_agent( + tmux_session_name: &str, + tmux_window_name: &str, + worktree_path: &str, +) -> Result { + eprintln!("[check_and_resume_agent] Checking agent status for window {}", tmux_window_name); + + // 1. Capture pane content to check if agent running + let capture = shell_command(&format!( + "tmux capture-pane -t {}:{} -p", + tmux_session_name, tmux_window_name + )).output(); + + let pane_content = match capture { + Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(), + Err(e) => { + eprintln!("[check_and_resume_agent] Failed to capture pane: {}", e); + return Ok(false); + } + }; + + // 2. Check if Claude agent is active + // Look for patterns indicating agent is running: + // - "claude>" prompt + // - "Claude Code" in output + // - "Assistant:" in conversation + let agent_running = pane_content.contains("claude>") + || pane_content.contains("Claude Code") + || pane_content.contains("Assistant:"); + + if agent_running { + eprintln!("[check_and_resume_agent] Agent already running"); + return Ok(false); // No action needed + } + + // 3. Check for Claude Code session that can be resumed + // Look for session file in worktree + let session_file = format!("{}/.claude/session.json", worktree_path); + let has_session = std::path::Path::new(&session_file).exists(); + + if !has_session { + eprintln!("[check_and_resume_agent] No session to resume"); + return Ok(false); // Can't resume, no session + } + + // 4. Resume agent with claude --resume + eprintln!("[check_and_resume_agent] Resuming agent..."); + let resume_cmd = "claude --resume --dangerously-skip-permissions"; + + let send_result = shell_command(&format!( + "tmux send-keys -t {}:{} '{}' Enter", + tmux_session_name, tmux_window_name, resume_cmd + )).output(); + + if let Err(e) = send_result { + eprintln!("[check_and_resume_agent] Failed to send resume command: {}", e); + return Ok(false); + } + + // 5. Wait for agent to start + std::thread::sleep(std::time::Duration::from_secs(2)); + + eprintln!("[check_and_resume_agent] Agent resume command sent"); + Ok(true) // Agent was resumed +} + /// 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..5656e691 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -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..1ef41f90 100644 --- a/ushadow/launcher/src/components/TicketDetailDialog.tsx +++ b/ushadow/launcher/src/components/TicketDetailDialog.tsx @@ -9,9 +9,12 @@ import { Plus, AlertCircle, CheckCircle, + Code2, } 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 +27,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 +65,11 @@ 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) // Load environments on mount useEffect(() => { @@ -70,6 +78,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 +125,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 +179,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 +218,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 +264,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 +281,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 +308,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 +416,12 @@ export function TicketDetailDialog({ {/* Error Message */} {error && (
- +
-

+

{error}

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

- Basic Information -

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