feat(ui): add aggregated diff viewer for planning tasks
Implements Task 14: PlanningDiffView (Window), PlanningDiffViewModel, ShowPlanningDiffModal callback wired in DetailsIslandView, and 5 xUnit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
// Set by the view so ReviewCombinedDiffCommand can show the planning diff modal
|
||||
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can show an error message
|
||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
@@ -570,8 +573,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[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;
|
||||
if (Task is null || ShowPlanningDiffModal is null) return;
|
||||
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main");
|
||||
await vm.InitializeAsync();
|
||||
await ShowPlanningDiffModal(vm);
|
||||
}
|
||||
|
||||
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
|
||||
|
||||
91
src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs
Normal file
91
src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
public sealed partial class PlanningDiffViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _planningTaskId;
|
||||
private readonly string _targetBranch;
|
||||
|
||||
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
|
||||
|
||||
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
|
||||
[ObservableProperty] private string _displayedDiff = "";
|
||||
[ObservableProperty] private bool _isCombinedMode;
|
||||
[ObservableProperty] private string? _combinedWarning;
|
||||
[ObservableProperty] private bool _isLoadingCombined;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch)
|
||||
{
|
||||
_worker = worker;
|
||||
_planningTaskId = planningTaskId;
|
||||
_targetBranch = targetBranch;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var items = await _worker.GetPlanningAggregateAsync(_planningTaskId);
|
||||
Subtasks.Clear();
|
||||
foreach (var i in items)
|
||||
Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff));
|
||||
SelectedSubtask = Subtasks.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value)
|
||||
{
|
||||
if (!IsCombinedMode)
|
||||
DisplayedDiff = value?.UnifiedDiff ?? "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleCombinedAsync()
|
||||
{
|
||||
if (IsCombinedMode)
|
||||
{
|
||||
IsLoadingCombined = true;
|
||||
try
|
||||
{
|
||||
var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId, _targetBranch);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
DisplayedDiff = "";
|
||||
CombinedWarning = "Could not build combined preview (hub error).";
|
||||
}
|
||||
else if (result.Success)
|
||||
{
|
||||
DisplayedDiff = result.UnifiedDiff ?? "";
|
||||
CombinedWarning = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var files = result.ConflictedFiles?.Count ?? 0;
|
||||
CombinedWarning = $"Cannot build combined preview: subtask {result.FirstConflictSubtaskId} conflicts with an earlier subtask ({files} files).";
|
||||
DisplayedDiff = "";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingCombined = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
|
||||
CombinedWarning = null;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
|
||||
}
|
||||
|
||||
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
@@ -44,6 +45,14 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
var modal = new PlanningDiffView { DataContext = planningDiffVm };
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ConfirmAsync = ShowConfirmAsync;
|
||||
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||
}
|
||||
|
||||
109
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml
Normal file
109
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml
Normal file
@@ -0,0 +1,109 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
|
||||
x:DataType="vm:PlanningDiffViewModel"
|
||||
Title="Planning — Combined diff"
|
||||
Width="1100" Height="700"
|
||||
SystemDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Border Background="{StaticResource SurfaceBrush}"
|
||||
BorderBrush="{StaticResource LineBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="36,Auto,*">
|
||||
|
||||
<!-- Title bar / drag handle -->
|
||||
<Border Grid.Row="0"
|
||||
x:Name="TitleBar"
|
||||
Background="{StaticResource Surface2Brush}"
|
||||
BorderBrush="{StaticResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
PointerPressed="TitleBar_PointerPressed">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="Planning — Combined diff"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextDimBrush}"/>
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-btn"
|
||||
Content="✕"
|
||||
FontSize="12"
|
||||
Command="{Binding CloseCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Toolbar row -->
|
||||
<StackPanel Grid.Row="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Margin="8,6">
|
||||
<ToggleButton Content="Preview combined" IsChecked="{Binding IsCombinedMode}"/>
|
||||
<TextBlock Text="{Binding CombinedWarning}"
|
||||
Foreground="Orange"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding CombinedWarning, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<TextBlock Text="Loading…"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsLoadingCombined}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Two-pane body -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="240,*">
|
||||
|
||||
<!-- Subtask list (left pane) -->
|
||||
<Border Grid.Column="0"
|
||||
BorderBrush="{StaticResource LineBrush}"
|
||||
BorderThickness="0,0,1,0"
|
||||
Background="{StaticResource DeepBrush}">
|
||||
<ListBox ItemsSource="{Binding Subtasks}"
|
||||
SelectedItem="{Binding SelectedSubtask}"
|
||||
IsEnabled="{Binding !IsCombinedMode}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SubtaskDiffRow">
|
||||
<Border Padding="10,8" Background="Transparent">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding DiffStat}"
|
||||
Opacity="0.7"
|
||||
FontFamily="{StaticResource MonoFamily}"
|
||||
FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Diff content (right pane) -->
|
||||
<Grid Grid.Column="1" Background="{StaticResource VoidBrush}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBox Text="{Binding DisplayedDiff, Mode=OneWay}"
|
||||
IsReadOnly="True"
|
||||
AcceptsReturn="True"
|
||||
FontFamily="Consolas,Menlo,monospace"
|
||||
FontSize="12"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="8"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
27
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml.cs
Normal file
27
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
public partial class PlanningDiffView : Window
|
||||
{
|
||||
public PlanningDiffView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is PlanningDiffViewModel vm)
|
||||
vm.CloseAction = Close;
|
||||
}
|
||||
|
||||
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
163
tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs
Normal file
163
tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class PlanningDiffViewModelTests
|
||||
{
|
||||
private sealed class FakePlanningWorker : IWorkerClient
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
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 bool IsConnected => false;
|
||||
|
||||
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
|
||||
public CombinedDiffResultDto? CombinedResult { get; set; }
|
||||
|
||||
public Task WakeQueueAsync() => Task.CompletedTask;
|
||||
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => 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<MergeTargetsDto?>(null);
|
||||
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
||||
Task.FromResult(AggregateResult);
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||
Task.FromResult(CombinedResult);
|
||||
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_PopulatesSubtasks()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[]
|
||||
{
|
||||
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
|
||||
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
|
||||
}
|
||||
};
|
||||
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Equal(2, vm.Subtasks.Count);
|
||||
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectingSubtask_InGroupedMode_SetsDisplayedDiff()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[]
|
||||
{
|
||||
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
|
||||
}
|
||||
};
|
||||
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.SelectedSubtask = vm.Subtasks[1];
|
||||
|
||||
Assert.Equal("DIFF-B", vm.DisplayedDiff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleCombined_Success_DisplaysUnifiedDiff()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[]
|
||||
{
|
||||
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||
},
|
||||
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
|
||||
};
|
||||
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.IsCombinedMode = true;
|
||||
|
||||
// Wait for the async toggle command to complete
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
|
||||
Assert.Null(vm.CombinedWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleCombined_Conflict_ShowsWarning()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[]
|
||||
{
|
||||
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||
},
|
||||
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
|
||||
};
|
||||
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.IsCombinedMode = true;
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.NotNull(vm.CombinedWarning);
|
||||
Assert.Contains("subtask-42", vm.CombinedWarning);
|
||||
Assert.Contains("2 files", vm.CombinedWarning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleCombined_HubReturnsNull_ShowsError()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[]
|
||||
{
|
||||
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||
},
|
||||
CombinedResult = null,
|
||||
};
|
||||
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.IsCombinedMode = true;
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.NotNull(vm.CombinedWarning);
|
||||
Assert.NotEmpty(vm.CombinedWarning!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user