feat(ui): surface review actions and WaitingForReview status in task rows
Adds Approve/Reject/Park/Cancel buttons with a feedback flyout, a review status chip, and a friendly status label for WaitingForReview tasks. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ public class StatusColorConverter : IValueConverter
|
||||
{
|
||||
"queued" => Brushes.DodgerBlue,
|
||||
"running" => Brushes.Orange,
|
||||
"waitingforreview" => Brushes.MediumPurple,
|
||||
"waiting_for_review" => Brushes.MediumPurple,
|
||||
"done" => Brushes.Green,
|
||||
"failed" => Brushes.Red,
|
||||
"manual" => Brushes.Gray,
|
||||
|
||||
@@ -184,6 +184,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- done → green (#6FA86B) -->
|
||||
<Style Selector="Border.chip.done">
|
||||
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DoneTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.done > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusDoneBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- idle → TextMute (#6B7973) -->
|
||||
<Style Selector="Border.chip.idle">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
|
||||
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
|
||||
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
|
||||
<SolidColorBrush x:Key="StatusDoneBrush" Color="#6FA86B" />
|
||||
|
||||
<!-- Subtle white overlay (island hairline border) -->
|
||||
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
|
||||
@@ -96,6 +97,8 @@
|
||||
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
|
||||
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
|
||||
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
|
||||
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
||||
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
||||
|
||||
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
|
||||
@@ -63,6 +63,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
@@ -78,11 +79,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
||||
|
||||
public string StatusLabel => Status == TaskStatus.WaitingForReview ? "Waiting for Review" : Status.ToString();
|
||||
|
||||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
{
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.WaitingForReview, _) => "review",
|
||||
(TaskStatus.Failed, _) => "error",
|
||||
(TaskStatus.Done, _) => "review",
|
||||
(TaskStatus.Done, _) => "done",
|
||||
(TaskStatus.Queued, true) => "waiting",
|
||||
(TaskStatus.Queued, false) => "queued",
|
||||
_ => "idle",
|
||||
@@ -91,7 +95,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
partial void OnStatusChanged(TaskStatus value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
|
||||
@@ -602,6 +602,42 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
catch { /* worker offline; the broadcast will reconcile when it returns */ }
|
||||
}
|
||||
|
||||
// ── Review actions (visible when a task is WaitingForReview) ─────────────
|
||||
// Each delegates to the worker hub, which performs the transition and
|
||||
// broadcasts TaskUpdated; the row refreshes from that broadcast.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ApproveReviewAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.ApproveReviewAsync(row.Id); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
}
|
||||
|
||||
public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
|
||||
{
|
||||
if (!row.IsWaitingForReview || _worker is null) return;
|
||||
if (string.IsNullOrWhiteSpace(feedback)) return;
|
||||
try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.RejectReviewToIdleAsync(row.Id); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CancelReviewAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.CancelReviewAsync(row.Id); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
}
|
||||
|
||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||
{
|
||||
if (row is null) return;
|
||||
|
||||
@@ -131,12 +131,30 @@
|
||||
<!-- Status chip -->
|
||||
<Border Classes="chip"
|
||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||
Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
||||
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
||||
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||
<TextBlock Text="{Binding Status}"/>
|
||||
<TextBlock Text="{Binding StatusLabel}"/>
|
||||
</Border>
|
||||
|
||||
<!-- Review actions (visible when WaitingForReview) -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="4"
|
||||
IsVisible="{Binding IsWaitingForReview}">
|
||||
<Button Classes="btn" Content="Approve" MinWidth="0" Padding="8,2"
|
||||
ToolTip.Tip="Approve — mark Done"
|
||||
Click="OnApproveReviewClick"/>
|
||||
<Button Classes="btn" Content="Reject" MinWidth="0" Padding="8,2"
|
||||
ToolTip.Tip="Reject with feedback and re-run"
|
||||
Click="OnRejectReviewClick"/>
|
||||
<Button Classes="btn" Content="Park" MinWidth="0" Padding="8,2"
|
||||
ToolTip.Tip="Send back to Idle for manual editing"
|
||||
Click="OnParkReviewClick"/>
|
||||
<Button Classes="btn" Content="Cancel" MinWidth="0" Padding="8,2"
|
||||
ToolTip.Tip="Cancel this task"
|
||||
Click="OnCancelReviewClick"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
|
||||
<Button Classes="icon-btn dequeue-btn"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
@@ -227,5 +245,36 @@
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
|
||||
<!-- Hidden reject-feedback anchor (its Flyout is shown from the Reject button) -->
|
||||
<Button Grid.Row="1" x:Name="RejectAnchor"
|
||||
Width="1" Height="1" Opacity="0"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
IsHitTestVisible="False" Focusable="False">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom" ShowMode="Standard">
|
||||
<Border Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="10"
|
||||
Padding="16" Width="320">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="title" Text="Reject & re-run"/>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="FEEDBACK FOR THE AGENT"
|
||||
Foreground="{DynamicResource TextDimBrush}" Opacity="0.6"/>
|
||||
<TextBox x:Name="RejectFeedback"
|
||||
AcceptsReturn="True" TextWrapping="Wrap"
|
||||
MinHeight="80" PlaceholderText="What should the agent fix?"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="0,4,0,0">
|
||||
<Button Classes="btn" Content="Cancel" Click="OnRejectCancelClick" MinWidth="76"/>
|
||||
<Button Content="Re-run" Classes="accent" Click="OnRejectConfirmClick" MinWidth="76"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -88,6 +88,43 @@ public partial class TaskRowView : UserControl
|
||||
await vm.SetStatusOnRowAsync(row, status);
|
||||
}
|
||||
|
||||
private async void OnApproveReviewClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.ApproveReviewCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnParkReviewClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.RejectReviewToIdleCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnCancelReviewClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.CancelReviewCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private void OnRejectReviewClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel) return;
|
||||
RejectFeedback.Text = "";
|
||||
RejectAnchor.Flyout?.ShowAt(RejectAnchor);
|
||||
}
|
||||
|
||||
private async void OnRejectConfirmClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
RejectAnchor.Flyout?.Hide();
|
||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
||||
var feedback = RejectFeedback.Text ?? "";
|
||||
if (string.IsNullOrWhiteSpace(feedback)) return;
|
||||
await vm.RejectReviewToQueueAsync(row, feedback);
|
||||
}
|
||||
|
||||
private void OnRejectCancelClick(object? sender, RoutedEventArgs e)
|
||||
=> RejectAnchor.Flyout?.Hide();
|
||||
|
||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
|
||||
@@ -9,8 +9,9 @@ public class TaskRowViewModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TaskStatus.Running, "running")]
|
||||
[InlineData(TaskStatus.WaitingForReview, "review")]
|
||||
[InlineData(TaskStatus.Failed, "error")]
|
||||
[InlineData(TaskStatus.Done, "review")]
|
||||
[InlineData(TaskStatus.Done, "done")]
|
||||
[InlineData(TaskStatus.Queued, "queued")]
|
||||
[InlineData(TaskStatus.Idle, "idle")]
|
||||
public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected)
|
||||
|
||||
Reference in New Issue
Block a user