feat(ui): Review-Merge-Button — Claude nimmt Waiting-for-Review pro Liste ab

Neuer Button in der virtuellen Review-Liste: öffnet das Merge-Helper-Auswahlmodal
im Review-Modus (alle WaitingForReview-Tasks repo-verknüpfter Listen, gruppiert
unter Listen-Headern, alle vorgetickt) und startet pro gewählter Liste eine
ConPTY-Handler-Session direkt in der Merge-Phase — via bestehendem Handoff-Spec
(nextPhase "merge"), kein Triage/Wait davor. Handler-Host-Task wie beim vollen
"Let Claude handle it"-Lauf (HandlerBaseCommit, IsManual).
This commit is contained in:
mika kuns
2026-08-27 08:44:33 +02:00
parent d0c726baa0
commit 741c7ac58d
11 changed files with 293 additions and 8 deletions
@@ -383,6 +383,115 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
}
// ---- Review mode (ConfigureForReview): all lists, WaitingForReview only ----
private async Task SeedReviewAcrossListsAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", WorkingDir = "C:\\repo1", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", WorkingDir = "C:\\repo2", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoRepo", CreatedAt = DateTime.UtcNow });
var order = 0;
void Add(string id, string listId, TaskStatus status, bool isManual = false) =>
db.Tasks.Add(new TaskEntity
{
Number = TestTaskNumbers.Next(),
Id = id, ListId = listId, Title = $"task {id}", IsManual = isManual,
Status = status, CreatedAt = DateTime.UtcNow, SortOrder = order++,
});
Add("r1", "L1", TaskStatus.WaitingForReview);
Add("r2", "L1", TaskStatus.WaitingForReview);
Add("i1", "L1", TaskStatus.Idle); // wrong status
Add("m1", "L1", TaskStatus.WaitingForReview, isManual: true); // handler/reminder task
Add("r3", "L2", TaskStatus.WaitingForReview);
Add("r4", "L3", TaskStatus.WaitingForReview); // list without working dir
await db.SaveChangesAsync();
}
[Fact]
public async Task ReviewMode_LoadsOnlyReviewTasksOfRepoLists_GroupedWithHeaders_AllTicked()
{
await SeedReviewAcrossListsAsync();
var vm = BuildVm();
vm.ConfigureForReview();
await vm.LoadAsync();
// Ordered by list name: Home (L2) before Work (L1); L3 (no repo), i1, m1 excluded.
Assert.Equal(new[] { "header:L2", "r3", "header:L1", "r1", "r2" }, vm.Tasks.Select(t => t.Id));
Assert.True(vm.Tasks[0].IsListHeader);
Assert.Equal("Home", vm.Tasks[0].Title);
Assert.True(vm.Tasks[2].IsListHeader);
Assert.Equal("Work", vm.Tasks[2].Title);
Assert.All(vm.Tasks.Where(t => !t.IsListHeader), t => Assert.True(t.IsSelected));
Assert.All(vm.Tasks.Where(t => t.IsListHeader), t => Assert.False(t.IsSelected));
Assert.True(vm.CanConfirm);
}
[Fact]
public async Task ReviewMode_SelectAll_DoesNotTickHeaders()
{
await SeedReviewAcrossListsAsync();
var vm = BuildVm();
vm.ConfigureForReview();
await vm.LoadAsync();
vm.SelectNoneCommand.Execute(null);
vm.SelectAllCommand.Execute(null);
Assert.All(vm.Tasks.Where(t => t.IsListHeader), t => Assert.False(t.IsSelected));
Assert.All(vm.Tasks.Where(t => !t.IsListHeader), t => Assert.True(t.IsSelected));
}
[Fact]
public async Task ReviewMode_ConfirmAndGroupSelectionByList_GroupsInRowOrder()
{
await SeedReviewAcrossListsAsync();
var vm = BuildVm();
vm.ConfigureForReview();
await vm.LoadAsync();
vm.ConfirmCommand.Execute(null);
var ids = await vm.Result.Task;
Assert.NotNull(ids);
Assert.Equal(new[] { "r3", "r1", "r2" }, ids);
var groups = vm.GroupSelectionByList(ids!);
Assert.Equal(2, groups.Count);
Assert.Equal("L2", groups[0].ListId);
Assert.Equal(new[] { "r3" }, groups[0].TaskIds);
Assert.Equal("L1", groups[1].ListId);
Assert.Equal(new[] { "r1", "r2" }, groups[1].TaskIds);
}
[Fact]
public async Task ReviewMode_ReconcileTick_KeepsUnticks_AndPicksUpNewReviewTasks()
{
await SeedReviewAcrossListsAsync();
var vm = BuildVm();
vm.ConfigureForReview();
await vm.LoadAsync();
vm.Tasks.Single(t => t.Id == "r1").IsSelected = false;
await using (var db = NewContext())
{
db.Tasks.Add(new TaskEntity
{
Number = TestTaskNumbers.Next(),
Id = "r5", ListId = "L1", Title = "arrived later",
Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 99,
});
await db.SaveChangesAsync();
}
await vm.ReconcileTickAsync();
Assert.False(vm.Tasks.Single(t => t.Id == "r1").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "r5").IsSelected);
Assert.Contains(vm.Tasks, t => t.Id == "header:L1" && t.IsListHeader);
}
private sealed class CountingDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
@@ -628,6 +628,42 @@ public class MissionControlViewModelTests : IDisposable
Assert.NotNull(error);
}
private sealed class RecordingReviewMergeWorker : StubWorkerClient
{
public string TaskId { get; } = "review-handler-task";
public readonly TaskCompletionSource<(string TaskId, IReadOnlyList<string> Ids, string Phase)> HandoffCall = new();
public override Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> Task.FromResult(TaskId);
public override Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
{
HandoffCall.TrySetResult((taskId, survivingTaskIds, nextPhase));
return base.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase, ct);
}
}
// The review-list "merge the reviews" entry: same host-task shape as the full list handler,
// but the pane's launch spec must come from the handoff path with phase "merge" and the
// freshly created handler task id — no triage phase first.
[Fact]
public async Task OpenMergeReviewConPtySessionAsync_AddsPane_LaunchingMergePhaseForHandlerTask()
{
var worker = new RecordingReviewMergeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeReviewConPtySessionAsync("L1", new[] { "r1", "r2" });
var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal(worker.TaskId, pane.TaskId);
Assert.True(pane.IsTaskBased);
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(worker.TaskId, call.TaskId);
Assert.Equal(new[] { "r1", "r2" }, call.Ids);
Assert.Equal("merge", call.Phase);
}
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
{