fix(ui): suppress auto-open conflict resolver during MCP-driven merges
review_task/continue_merge on a planning parent always leaves conflicts in the tree, and the UI auto-opened the resolver on every PlanningMergeConflict broadcast regardless of who started the merge -- so a running Claude session resolving a unit-merge conflict could race a human editing the same shared checkout in a resolver window neither of them asked for. PlanningMergeOrchestrator.StartAsync now takes an externallyDriven flag (set by ExternalMcpService's MCP-driven review_task path, left false for the UI's ApproveReview) that rides along on the PlanningMergeConflict broadcast. The UI only auto-opens the resolver when it's false; otherwise it shows a persistent banner with a manual "Open resolver" button, cleared on PlanningMergeAborted/PlanningCompleted. A new GetActiveExternalConflictsAsync query (checked against GitService.IsMidMergeAsync rather than the in-memory flag alone) lets the UI resync the banner on reconnect instead of trusting a one-shot broadcast that isn't replayed after a restart. The childless single-task conflict path was checked and needed no change -- it only broadcasts the generic TaskUpdated, never PlanningMergeConflict.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
// Covers the MCP-driven-merge popup fix: a unit-merge conflict started by an MCP session
|
||||
// (review_task/continue_merge) must not auto-open the in-app resolver — two parties editing the
|
||||
// same shared checkout at once is how the original bug manifested. The UI-driven path (a human
|
||||
// clicking Approve) keeps opening the resolver as before.
|
||||
public class IslandsShellViewModelExternalMergeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExternallyDriven_ShowsBanner()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: true);
|
||||
|
||||
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiDriven_DoesNotShowExternalBanner()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: false);
|
||||
|
||||
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aborted_ClearsBanner()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||
|
||||
vm.OnPlanningMergeAborted("plan1");
|
||||
|
||||
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_ClearsBanner()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||
|
||||
vm.OnPlanningMergeCompleted("plan1");
|
||||
|
||||
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrelatedPlanningTaskEnding_LeavesBannerVisible()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||
|
||||
vm.OnPlanningMergeCompleted("some-other-plan");
|
||||
|
||||
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaterConflictOnSamePlanningTask_UpdatesInPlaceWithoutDuplicating()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
vm.OnPlanningMergeConflict("plan1", "subA", Array.Empty<string>(), externallyDriven: true);
|
||||
vm.OnPlanningMergeConflict("plan1", "subB", Array.Empty<string>(), externallyDriven: true);
|
||||
Assert.True(vm.IsExternalMergeBannerVisible);
|
||||
|
||||
// A single terminal event for that planning task clears it entirely — proves the second
|
||||
// conflict updated the same entry rather than piling up a second one.
|
||||
vm.OnPlanningMergeCompleted("plan1");
|
||||
|
||||
Assert.False(vm.IsExternalMergeBannerVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenExternalMergeConflictCommand_NoPendingConflict_DoesNotThrow()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
|
||||
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenExternalMergeConflictCommand_NoDialogsWired_DoesNotThrow()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
|
||||
|
||||
// Dialogs/ConflictResolverFactory aren't wired in the test-only ctor — a deliberate
|
||||
// click must degrade gracefully rather than throw, same as the existing UI-driven path.
|
||||
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user