feat(ui): add merge-target dropdown and merge-all controls to planning detail

- Add SubtaskDiffDto and CombinedDiffResultDto to PlanningDtos.cs
- Extend IWorkerClient with 5 planning merge methods and 5 events
- Implement methods and hub subscriptions on WorkerClient
- Add Status and WorktreeState to SubtaskRowViewModel
- Add MergeTargetBranches, SelectedMergeTarget, CanMergeAll,
  MergeAllDisabledReason, MergeAllError, RecomputeCanMergeAll,
  MergeAllCommand, ReviewCombinedDiffCommand (Task 14 TODO)
  to DetailsIslandViewModel
- Add planning merge section to DetailsIslandView.axaml
  (merge target ComboBox + buttons + error label), gated on
  Task.IsPlanningParent
- Add 4 xUnit tests covering CanMergeAll logic and DTO shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 16:22:27 +02:00
parent 2cab33d708
commit 4c6fd9f024
6 changed files with 409 additions and 0 deletions

View File

@@ -6,10 +6,22 @@ public interface IWorkerClient
event Action<string>? WorktreeUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent;
Task WakeQueueAsync();
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId);
}

View File

@@ -16,3 +16,19 @@ public sealed record PlanningSessionResumeInfo(
string WorkingDir,
string ClaudeSessionId,
string McpConfigPath);
public sealed record SubtaskDiffDto(
string SubtaskId,
string Title,
string BranchName,
string BaseCommit,
string HeadCommit,
string? DiffStat,
string UnifiedDiff);
public sealed record CombinedDiffResultDto(
bool Success,
string? IntegrationBranch,
string? UnifiedDiff,
string? FirstConflictSubtaskId,
IReadOnlyList<string>? ConflictedFiles);

View File

@@ -49,6 +49,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
public WorkerClient(string signalRUrl)
{
_hub = new HubConnectionBuilder()
@@ -123,6 +129,31 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
{
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
});
_hub.On<string, string>("PlanningSubtaskMerged", (planningTaskId, subtaskId) =>
{
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
});
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
});
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
{
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId));
});
_hub.On<string>("PlanningCompleted", planningTaskId =>
{
Dispatcher.UIThread.Post(() => PlanningCompletedEvent?.Invoke(planningTaskId));
});
}
public Task StartAsync()
@@ -362,6 +393,46 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
{
try
{
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
return result ?? [];
}
catch
{
return [];
}
}
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
{
try
{
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
}
catch
{
return null;
}
}
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
}
public async Task ContinuePlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
}
public async Task AbortPlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
}
// IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct);

View File

@@ -112,6 +112,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string _newSubtaskTitle = "";
// Planning merge controls
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
private bool _canMergeAll;
[ObservableProperty] private string? _mergeAllDisabledReason;
[ObservableProperty] private string? _mergeAllError;
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
@@ -185,6 +194,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_worker.WorktreeUpdatedEvent += taskId =>
{
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
};
_worker.TaskUpdatedEvent += taskId =>
{
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
};
Subtasks.CollectionChanged += (_, _) =>
{
RecomputeCanMergeAll();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
};
}
@@ -313,6 +334,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(TaskIdBadge));
Log.Clear();
Subtasks.Clear();
MergeTargetBranches.Clear();
SelectedMergeTarget = null;
CanMergeAll = false;
MergeAllDisabledReason = null;
MergeAllError = null;
_claudeBuf.Clear();
if (row == null)
@@ -388,6 +414,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
ct.ThrowIfCancellationRequested();
foreach (var s in subs)
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
{
await LoadPlanningChildrenAsync(row.Id, ct);
}
}
catch (OperationCanceledException) { }
}
@@ -445,6 +477,119 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
return path;
}
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var children = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.ParentTaskId == parentTaskId)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
foreach (var child in children)
{
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
if (existing != null)
{
existing.Status = child.Status;
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
}
}
if (MergeTargetBranches.Count == 0)
{
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
if (childWithWorktree != null)
{
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
if (targets != null)
{
MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches)
MergeTargetBranches.Add(b);
SelectedMergeTarget = targets.DefaultBranch;
}
}
}
RecomputeCanMergeAll();
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
{
if (Task is null) return;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var child = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
if (child == null) return;
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
if (existing != null)
{
existing.Status = child.Status;
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
}
RecomputeCanMergeAll();
}
catch { /* best-effort */ }
}
private void RecomputeCanMergeAll()
{
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
if (notDone > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
return;
}
var badWt = Subtasks.FirstOrDefault(c =>
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
if (badWt is not null)
{
CanMergeAll = false;
MergeAllDisabledReason = "at least one worktree was discarded/kept";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
}
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
// TODO(Task 14): open PlanningDiffView once it exists
await System.Threading.Tasks.Task.CompletedTask;
}
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private async System.Threading.Tasks.Task MergeAllAsync()
{
MergeAllError = null;
try
{
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
}
catch (Exception ex)
{
MergeAllError = ex.Message;
}
}
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
{
try
@@ -665,4 +810,6 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
public required string Id { get; init; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private bool _done;
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
}

View File

@@ -138,6 +138,35 @@
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="0">
<!-- Planning merge section — visible only for planning parent tasks -->
<Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
IsVisible="{Binding Task.IsPlanningParent}">
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="MERGE" Margin="0,0,0,2"/>
<StackPanel Spacing="4">
<TextBlock Text="Merge target"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"/>
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Review combined diff"
Command="{Binding ReviewCombinedDiffCommand}"/>
<Button Content="Merge all subtasks"
Command="{Binding MergeAllCommand}"
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
</StackPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="OrangeRed"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</Border>
<!-- Steps section -->
<Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"

View File

@@ -0,0 +1,134 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandPlanningTests
{
// Minimal shim exercising RecomputeCanMergeAll logic without needing WorkerClient
private sealed class PlanningVmShim
{
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
public bool CanMergeAll { get; private set; }
public string? MergeAllDisabledReason { get; private set; }
public void RecomputeCanMergeAll()
{
var notDone = Subtasks.Count(c => c.Status != TaskStatus.Done);
if (notDone > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
return;
}
var badWt = Subtasks.FirstOrDefault(c =>
c.WorktreeState == WorktreeState.Discarded ||
c.WorktreeState == WorktreeState.Kept);
if (badWt is not null)
{
CanMergeAll = false;
MergeAllDisabledReason = "at least one worktree was discarded/kept";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string, string>? TaskMessageEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
public MergeTargetsDto? MergeTargetsResult { get; set; }
public Task WakeQueueAsync() => Task.CompletedTask;
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult<CombinedDiffResultDto?>(null);
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public void FireTaskUpdated(string id) => TaskUpdatedEvent?.Invoke(id);
public void FireWorktreeUpdated(string id) => WorktreeUpdatedEvent?.Invoke(id);
}
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt };
[Fact]
public void CanMergeAll_AllChildrenDoneActiveWorktrees_True()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.RecomputeCanMergeAll();
Assert.True(shim.CanMergeAll);
Assert.Null(shim.MergeAllDisabledReason);
}
[Fact]
public void CanMergeAll_AnyChildNotDone_FalseWithReason()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Running, WorktreeState.Active));
shim.RecomputeCanMergeAll();
Assert.False(shim.CanMergeAll);
Assert.NotNull(shim.MergeAllDisabledReason);
Assert.Contains("1 subtask", shim.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("not done", shim.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CanMergeAll_AnyChildDiscarded_FalseWithReason()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Discarded));
shim.RecomputeCanMergeAll();
Assert.False(shim.CanMergeAll);
Assert.NotNull(shim.MergeAllDisabledReason);
Assert.True(
shim.MergeAllDisabledReason!.Contains("discarded", StringComparison.OrdinalIgnoreCase) ||
shim.MergeAllDisabledReason.Contains("kept", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void MergeTargetBranches_LoadedFromWorkerOnPlanningParent()
{
var fake = new FakeWorkerClient();
fake.MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" });
var result = fake.GetMergeTargetsAsync("any-task-id").GetAwaiter().GetResult();
Assert.NotNull(result);
Assert.Equal("main", result!.DefaultBranch);
Assert.Contains("main", result.LocalBranches);
Assert.Contains("dev", result.LocalBranches);
}
}