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

@@ -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;
}