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:
mika kuns
2026-08-06 12:04:52 +02:00
parent 8247a749a0
commit 5d1d2d89d0
15 changed files with 379 additions and 15 deletions
@@ -102,6 +102,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries.
private readonly Dictionary<string, string> _externalMergeConflicts = new();
[ObservableProperty] private bool _isExternalMergeBannerVisible;
[ObservableProperty]
private double _windowWidth = 1280;
@@ -178,13 +184,59 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_primeStatusTimer.Start();
}
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
public void OnPlanningMergeConflict(
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
if (externallyDriven)
{
// An MCP session (review_task/continue_merge) is driving this merge — it owns
// resolution. Auto-opening the resolver here raced with the session's own writes
// (two parties editing the same shared checkout at once); show a banner instead and
// leave the resolver reachable only via a deliberate click.
_externalMergeConflicts[planningTaskId] = subtaskId;
IsExternalMergeBannerVisible = true;
return;
}
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
private void ClearExternalMergeConflict(string planningTaskId)
{
_externalMergeConflicts.Remove(planningTaskId);
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
private async Task RefreshExternalMergeConflictsAsync()
{
if (Worker is null) return;
IReadOnlyList<PlanningMergeConflictStateDto> active;
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
catch { return; }
_externalMergeConflicts.Clear();
foreach (var c in active)
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
[RelayCommand]
private Task OpenExternalMergeConflictAsync()
{
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
@@ -287,6 +339,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
Worker.PrimeFired += OnPrimeFired;
_clearTimer.Elapsed += (_, _) =>
{