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:
mika kuns
2026-06-02 07:46:37 +02:00
parent 21f1cf2a85
commit 6c27ffbdca
8 changed files with 152 additions and 9 deletions

View File

@@ -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,

View File

@@ -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}" />

View File

@@ -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%">

View File

@@ -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,20 +79,25 @@ 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.Failed, _) => "error", (TaskStatus.WaitingForReview, _) => "review",
(TaskStatus.Done, _) => "review", (TaskStatus.Failed, _) => "error",
(TaskStatus.Queued, true) => "waiting", (TaskStatus.Done, _) => "done",
(TaskStatus.Queued, false) => "queued", (TaskStatus.Queued, true) => "waiting",
_ => "idle", (TaskStatus.Queued, false) => "queued",
_ => "idle",
}; };
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));

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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