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:
mika kuns
2026-04-24 16:39:38 +02:00
parent 389d9045d5
commit a6ebff3f34
6 changed files with 406 additions and 2 deletions

View File

@@ -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();

View 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);

View File

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

View 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>

View 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);
}
}