From 4bf4a270364225d9362ed6b2d4c9217af21fedee Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 5 Jun 2026 10:30:43 +0200 Subject: [PATCH] feat(ui): route single-task merge conflicts into a resolution seam --- .../Islands/DetailsIslandViewModel.cs | 30 ++++-- .../DetailsIslandConflictSeamTests.cs | 96 +++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 13b13bb..5d92c91 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -398,6 +398,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Set by the view so DeleteTaskCommand can show an error message public Func? ShowErrorAsync { get; set; } + // Invoked when a single-task merge/approve hits a conflict. Wired by the + // integrator to Layer C's conflict resolver. Args: (taskId, targetBranch). + public Func? RequestConflictResolution { get; set; } + private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch { ClaudeDo.Data.Models.TaskStatus.Queued => "queued", @@ -1175,9 +1179,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task"); if (result.Status == "conflict") { - var (text, _, _) = MergePreviewPresenter.Describe( - new MergePreviewDto("conflict", result.ConflictFiles, 0)); - MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + if (RequestConflictResolution is not null) + { + await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? ""); + } + else + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", result.ConflictFiles, 0)); + MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + } } else { @@ -1458,9 +1469,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); if (result?.Status == "conflict") { - var (text, _, _) = MergePreviewPresenter.Describe( - new MergePreviewDto("conflict", result.ConflictFiles, 0)); - MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + if (RequestConflictResolution is not null) + { + await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? ""); + } + else + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", result.ConflictFiles, 0)); + MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + } } } catch { /* stale review action; broadcast reconciles */ } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs new file mode 100644 index 0000000..06c7a9a --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs @@ -0,0 +1,96 @@ +using System.Collections.ObjectModel; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class DetailsIslandConflictSeamTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandConflictSeamTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_conflict_seam_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => + Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => + Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private sealed class ConflictApproveWorkerClient : StubWorkerClient + { + public override bool IsConnected => true; + public override Task ApproveReviewAsync(string taskId, string targetBranch) => + Task.FromResult(new MergeResultDto("conflict", new[] { "a.cs" }, null)); + } + + private DetailsIslandViewModel BuildVm(StubWorkerClient worker) + { + var factory = new TestDbFactory(NewContext); + return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi()); + } + + [Fact] + public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam() + { + const string taskId = "task-conflict-1"; + + var vm = BuildVm(new ConflictApproveWorkerClient()); + vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.WaitingForReview }); + vm.SelectedMergeTarget = "main"; + + string? capturedTaskId = null; + string? capturedTarget = null; + vm.RequestConflictResolution = (tid, target) => + { + capturedTaskId = tid; + capturedTarget = target; + return Task.CompletedTask; + }; + + await vm.ApproveReviewCommand.ExecuteAsync(null); + + Assert.Equal(taskId, capturedTaskId); + Assert.Equal("main", capturedTarget); + } +}