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:
@@ -35,7 +35,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||
/// is true when an MCP session (not the UI) started the unit merge — the resolver must not
|
||||
/// auto-open in that case.</summary>
|
||||
event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
event Action<string>? PlanningMergeAbortedEvent;
|
||||
event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
@@ -117,6 +120,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||
/// <summary>Unit merges currently paused on a conflict that an MCP session started. Called
|
||||
/// on (re)connect to recover the "don't auto-open" banner state after a UI restart.</summary>
|
||||
Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync();
|
||||
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||
|
||||
@@ -66,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
@@ -182,9 +182,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
|
||||
});
|
||||
|
||||
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
|
||||
_hub.On<string, string, IReadOnlyList<string>, bool>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles, externallyDriven) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
|
||||
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles, externallyDriven));
|
||||
});
|
||||
|
||||
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
|
||||
@@ -578,6 +578,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||
=> await TryInvokeAsync<List<PlanningMergeConflictStateDto>>("GetActiveExternalPlanningMergeConflicts")
|
||||
?? [];
|
||||
|
||||
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||
@@ -692,6 +696,7 @@ public sealed record LaunchSpec(
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public sealed record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
|
||||
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
public sealed record OnlineInboxStateDto(
|
||||
|
||||
@@ -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 += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -258,5 +258,27 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- External merge popup card — bottom-left corner, while an MCP session owns an
|
||||
unresolved unit-merge conflict. Does not auto-dismiss; stays until the merge ends. -->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||
VerticalAlignment="Bottom" HorizontalAlignment="Left"
|
||||
Margin="16,0,0,30"
|
||||
ZIndex="100"
|
||||
IsVisible="{Binding IsExternalMergeBannerVisible}"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource PeatBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="14,10">
|
||||
<StackPanel Spacing="8" Width="300">
|
||||
<TextBlock Classes="body" Text="{loc:Tr shell.externalMerge.banner}" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr shell.externalMerge.open}"
|
||||
Command="{Binding OpenExternalMergeConflictCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
Reference in New Issue
Block a user