feat(ui): add conflict resolution dialog for planning merge-all
Opens a modal when PlanningMergeConflict fires, listing conflicted files with options to open in VS Code, continue, or abort the merge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ClaudeDoDbContext>? _dbFactory;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? 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<string> conflictedFiles)
|
||||
{
|
||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
|
||||
}
|
||||
|
||||
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> 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<ClaudeDoDbContext> 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())
|
||||
|
||||
@@ -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<string> 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<string> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
65
src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
Normal file
65
src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
Normal file
@@ -0,0 +1,65 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
||||
x:DataType="vm:ConflictResolutionViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
||||
Title="Merge conflict"
|
||||
Width="560" SizeToContent="Height"
|
||||
SystemDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Border Background="{StaticResource SurfaceBrush}"
|
||||
BorderBrush="{StaticResource LineBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="36,*">
|
||||
|
||||
<!-- Title bar / drag handle -->
|
||||
<Border Grid.Row="0"
|
||||
x:Name="TitleBar"
|
||||
Background="{StaticResource Surface2Brush}"
|
||||
BorderBrush="{StaticResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
PointerPressed="TitleBar_PointerPressed">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="Merge conflict"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextDimBrush}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<StackPanel Grid.Row="1" Spacing="12" Margin="16" MinWidth="520">
|
||||
<TextBlock FontWeight="SemiBold" FontSize="16"
|
||||
Text="{Binding SubtaskTitle, StringFormat='Conflicts in subtask: {0}'}"/>
|
||||
<TextBlock Text="{Binding TargetBranch, StringFormat='Merging into: {0}'}" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontFamily="Consolas,Menlo,monospace"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding VsCodeError}" Foreground="OrangeRed"
|
||||
IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding ActionError}" Foreground="OrangeRed"
|
||||
IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,4,0,4">
|
||||
<Button Content="Open all in VS Code" Command="{Binding OpenInVsCodeCommand}"/>
|
||||
<Button Content="I've resolved — continue" Command="{Binding ContinueCommand}"/>
|
||||
<Button Content="Abort this merge" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
public partial class ConflictResolutionView : Window
|
||||
{
|
||||
public ConflictResolutionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ConflictResolutionViewModel vm)
|
||||
vm.CloseRequested = Close;
|
||||
}
|
||||
|
||||
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user