diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 9083a92..859b1a7 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -55,6 +55,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? PlanningMergeAbortedEvent; public event Action? PlanningCompletedEvent; + public string? LastMergeAllTarget { get; private set; } + public WorkerClient(string signalRUrl) { _hub = new HubConnectionBuilder() @@ -420,6 +422,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) { + LastMergeAllTarget = targetBranch; await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch); } diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 50e760b..4a7f600 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -4,9 +4,12 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; +using ClaudeDo.Ui.ViewModels.Planning; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; @@ -27,6 +30,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase private readonly UpdateCheckService _updateCheck; private readonly InstallerLocator _installerLocator; + private readonly IDbContextFactory? _dbFactory; + + // Set by MainWindow to open the conflict resolution dialog. + public Func? ShowConflictDialog { get; set; } [ObservableProperty] private bool _isUpdateBannerVisible; [ObservableProperty] private string? _updateBannerLatestVersion; @@ -84,6 +91,47 @@ public sealed partial class IslandsShellViewModel : ViewModelBase WorkerLogText = null; } + private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles) + { + // Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post). + _ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles); + } + + private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles) + { + if (ShowConflictDialog == null || _dbFactory == null) return; + + string subtaskTitle = subtaskId; + string worktreePath = System.Environment.CurrentDirectory; + string targetBranch = Worker?.LastMergeAllTarget ?? "main"; + + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks + .Include(t => t.Worktree) + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == subtaskId); + if (entity != null) + { + subtaskTitle = entity.Title; + if (entity.Worktree?.Path is { } p) + worktreePath = p; + } + } + catch { /* Non-fatal: fall back to subtaskId and cwd */ } + + var vm = new ConflictResolutionViewModel( + Worker!, + planningTaskId, + subtaskTitle, + targetBranch, + conflictedFiles, + worktreePath); + + await ShowConflictDialog(vm); + } + // For tests only — does NOT wire up events. internal IslandsShellViewModel() { } @@ -93,11 +141,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase DetailsIslandViewModel details, WorkerClient worker, UpdateCheckService updateCheck, - InstallerLocator installerLocator) + InstallerLocator installerLocator, + IDbContextFactory dbFactory) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; _updateCheck = updateCheck; _installerLocator = installerLocator; + _dbFactory = dbFactory; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); @@ -122,6 +172,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase } }; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; + Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; _clearTimer.Elapsed += (_, _) => { if (Dispatcher.UIThread.CheckAccess()) diff --git a/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs new file mode 100644 index 0000000..18d1e54 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Planning; + +public sealed partial class ConflictResolutionViewModel : ObservableObject +{ + private readonly IWorkerClient _worker; + private readonly string _planningTaskId; + private readonly string _worktreePath; + + public string SubtaskTitle { get; } + public string TargetBranch { get; } + public IReadOnlyList ConflictedFiles { get; } + + [ObservableProperty] private string? _vsCodeError; + [ObservableProperty] private string? _actionError; + + public Action? CloseRequested { get; set; } + + public ConflictResolutionViewModel( + IWorkerClient worker, + string planningTaskId, + string subtaskTitle, + string targetBranch, + IReadOnlyList conflictedFiles, + string worktreePath) + { + _worker = worker; + _planningTaskId = planningTaskId; + _worktreePath = worktreePath; + SubtaskTitle = subtaskTitle; + TargetBranch = targetBranch; + ConflictedFiles = conflictedFiles; + } + + [RelayCommand] + private void OpenInVsCode() + { + try + { + var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\"")); + Process.Start(new ProcessStartInfo + { + FileName = "code", + Arguments = args, + WorkingDirectory = _worktreePath, + UseShellExecute = true, + }); + VsCodeError = null; + } + catch (Exception ex) + { + VsCodeError = $"Could not launch VS Code: {ex.Message}. Paths are listed above — copy them manually."; + } + } + + [RelayCommand] + private async Task ContinueAsync() + { + ActionError = null; + try + { + await _worker.ContinuePlanningMergeAsync(_planningTaskId); + CloseRequested?.Invoke(); + } + catch (Exception ex) { ActionError = ex.Message; } + } + + [RelayCommand] + private async Task AbortAsync() + { + ActionError = null; + try + { + await _worker.AbortPlanningMergeAsync(_planningTaskId); + CloseRequested?.Invoke(); + } + catch (Exception ex) { ActionError = ex.Message; } + } +} diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index 01a17aa..463700f 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Avalonia.Input; using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.Views.Planning; namespace ClaudeDo.Ui.Views; @@ -10,6 +11,19 @@ public partial class MainWindow : Window { InitializeComponent(); KeyDown += OnWindowKeyDown; + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is IslandsShellViewModel vm) + { + vm.ShowConflictDialog = async (conflictVm) => + { + var modal = new ConflictResolutionView { DataContext = conflictVm }; + await modal.ShowDialog(this); + }; + } } private void OnWindowKeyDown(object? sender, KeyEventArgs e) diff --git a/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml b/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml new file mode 100644 index 0000000..b2b2e2f --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +