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,
|
"queued" => Brushes.DodgerBlue,
|
||||||
"running" => Brushes.Orange,
|
"running" => Brushes.Orange,
|
||||||
|
"waitingforreview" => Brushes.MediumPurple,
|
||||||
|
"waiting_for_review" => Brushes.MediumPurple,
|
||||||
"done" => Brushes.Green,
|
"done" => Brushes.Green,
|
||||||
"failed" => Brushes.Red,
|
"failed" => Brushes.Red,
|
||||||
"manual" => Brushes.Gray,
|
"manual" => Brushes.Gray,
|
||||||
|
|||||||
@@ -184,6 +184,15 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||||
</Style>
|
</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) -->
|
<!-- idle → TextMute (#6B7973) -->
|
||||||
<Style Selector="Border.chip.idle">
|
<Style Selector="Border.chip.idle">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
|
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
|
||||||
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
|
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
|
||||||
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
|
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
|
||||||
|
<SolidColorBrush x:Key="StatusDoneBrush" Color="#6FA86B" />
|
||||||
|
|
||||||
<!-- Subtle white overlay (island hairline border) -->
|
<!-- Subtle white overlay (island hairline border) -->
|
||||||
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
|
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
|
||||||
@@ -96,6 +97,8 @@
|
|||||||
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
|
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
|
||||||
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
|
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
|
||||||
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
|
<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) -->
|
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
<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 HasSteps => StepsCount > 0;
|
||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
|
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||||
@@ -78,11 +79,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||||
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
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
|
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||||
{
|
{
|
||||||
(TaskStatus.Running, _) => "running",
|
(TaskStatus.Running, _) => "running",
|
||||||
|
(TaskStatus.WaitingForReview, _) => "review",
|
||||||
(TaskStatus.Failed, _) => "error",
|
(TaskStatus.Failed, _) => "error",
|
||||||
(TaskStatus.Done, _) => "review",
|
(TaskStatus.Done, _) => "done",
|
||||||
(TaskStatus.Queued, true) => "waiting",
|
(TaskStatus.Queued, true) => "waiting",
|
||||||
(TaskStatus.Queued, false) => "queued",
|
(TaskStatus.Queued, false) => "queued",
|
||||||
_ => "idle",
|
_ => "idle",
|
||||||
@@ -91,7 +95,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
partial void OnStatusChanged(TaskStatus value)
|
partial void OnStatusChanged(TaskStatus value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusChipClass));
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
|
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(IsWaiting));
|
OnPropertyChanged(nameof(IsWaiting));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
|
|||||||
@@ -602,6 +602,42 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch { /* worker offline; the broadcast will reconcile when it returns */ }
|
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)
|
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
|
|||||||
@@ -131,12 +131,30 @@
|
|||||||
<!-- Status chip -->
|
<!-- Status chip -->
|
||||||
<Border Classes="chip"
|
<Border Classes="chip"
|
||||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
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.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
||||||
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||||
<TextBlock Text="{Binding Status}"/>
|
<TextBlock Text="{Binding StatusLabel}"/>
|
||||||
</Border>
|
</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) -->
|
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
|
||||||
<Button Classes="icon-btn dequeue-btn"
|
<Button Classes="icon-btn dequeue-btn"
|
||||||
IsVisible="{Binding CanRemoveFromQueue}"
|
IsVisible="{Binding CanRemoveFromQueue}"
|
||||||
@@ -227,5 +245,36 @@
|
|||||||
</Flyout>
|
</Flyout>
|
||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
</Button>
|
</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>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -88,6 +88,43 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.SetStatusOnRowAsync(row, status);
|
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)
|
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TaskRowViewModel row) return;
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ public class TaskRowViewModelTests
|
|||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(TaskStatus.Running, "running")]
|
[InlineData(TaskStatus.Running, "running")]
|
||||||
|
[InlineData(TaskStatus.WaitingForReview, "review")]
|
||||||
[InlineData(TaskStatus.Failed, "error")]
|
[InlineData(TaskStatus.Failed, "error")]
|
||||||
[InlineData(TaskStatus.Done, "review")]
|
[InlineData(TaskStatus.Done, "done")]
|
||||||
[InlineData(TaskStatus.Queued, "queued")]
|
[InlineData(TaskStatus.Queued, "queued")]
|
||||||
[InlineData(TaskStatus.Idle, "idle")]
|
[InlineData(TaskStatus.Idle, "idle")]
|
||||||
public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected)
|
public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected)
|
||||||
|
|||||||
Reference in New Issue
Block a user