diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index b6b04c5..279ee31 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -6,10 +6,22 @@ public interface IWorkerClient event Action? WorktreeUpdatedEvent; event Action? TaskMessageEvent; + event Action? PlanningMergeStartedEvent; + event Action? PlanningSubtaskMergedEvent; + event Action>? PlanningMergeConflictEvent; + event Action? PlanningMergeAbortedEvent; + event Action? 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 GetPendingDraftCountAsync(string taskId, CancellationToken ct = default); + Task GetMergeTargetsAsync(string taskId); + Task> GetPlanningAggregateAsync(string planningTaskId); + Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch); + Task MergeAllPlanningAsync(string planningTaskId, string targetBranch); + Task ContinuePlanningMergeAsync(string planningTaskId); + Task AbortPlanningMergeAsync(string planningTaskId); } diff --git a/src/ClaudeDo.Ui/Services/PlanningDtos.cs b/src/ClaudeDo.Ui/Services/PlanningDtos.cs index 71a553e..c6b6451 100644 --- a/src/ClaudeDo.Ui/Services/PlanningDtos.cs +++ b/src/ClaudeDo.Ui/Services/PlanningDtos.cs @@ -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? ConflictedFiles); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index a330714..9083a92 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -49,6 +49,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? ListUpdatedEvent; public event Action? WorkerLogReceivedEvent; + public event Action? PlanningMergeStartedEvent; + public event Action? PlanningSubtaskMergedEvent; + public event Action>? PlanningMergeConflictEvent; + public event Action? PlanningMergeAbortedEvent; + public event Action? 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("PlanningMergeStarted", (planningTaskId, targetBranch) => + { + Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch)); + }); + + _hub.On("PlanningSubtaskMerged", (planningTaskId, subtaskId) => + { + Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId)); + }); + + _hub.On>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) => + { + Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles)); + }); + + _hub.On("PlanningMergeAborted", planningTaskId => + { + Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId)); + }); + + _hub.On("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 GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); + public async Task> GetPlanningAggregateAsync(string planningTaskId) + { + try + { + var result = await _hub.InvokeAsync>("GetPlanningAggregate", planningTaskId); + return result ?? []; + } + catch + { + return []; + } + } + + public async Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) + { + try + { + return await _hub.InvokeAsync("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); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 29cc32f..66c0e52 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -112,6 +112,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase [ObservableProperty] private string _newSubtaskTitle = ""; + // Planning merge controls + [ObservableProperty] private ObservableCollection _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; } diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 8e9670f..cc61a55 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -138,6 +138,35 @@ + + + + + + + + + +