feat(review): submit interactive (ConPTY) work for review

An embedded ConPTY session leaves its worktree changed but never touches
task status, so hand-driven work had no path into the review/merge flow.

Add SubmitTaskForReview: commit the worktree (same auto-commit as a headless
run), then transition Idle/Failed -> WaitingForReview via the new
TaskStateService.SubmitInteractiveForReviewAsync. Approve then merges it.

Surfaces: a 'Submit for review' button in the detail work console (shown for
an Idle/Failed task with a worktree) and on the ConPTY Command Center pane
header (task-based panes; closes the pane on success). Tests cover the new
transition (Idle/Failed accepted, Running/Queued/Done/Review rejected).
This commit is contained in:
mika kuns
2026-07-24 13:05:22 +02:00
parent 34b17537fc
commit 109a35c505
15 changed files with 178 additions and 3 deletions
@@ -76,6 +76,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
// Picks up a task's Claude session in a real terminal window (--resume).
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
/// <summary>Commits an interactively-worked task's worktree and moves it to WaitingForReview
/// (the only path that flips a hand-driven ConPTY session into the review/merge pipeline).</summary>
Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY terminal to open an interactive session
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
+3
View File
@@ -513,6 +513,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
@@ -413,6 +413,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
ReviewDiffViewed = false;
ApproveReviewCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowReviewDiffHint));
OnPropertyChanged(nameof(CanSubmitForReview));
SubmitForReviewCommand.NotifyCanExecuteChanged();
AgentSettings.IsRunning = IsRunning;
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
@@ -758,6 +760,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
NotifySessionSections();
OnPropertyChanged(nameof(CanSubmitForReview));
SubmitForReviewCommand.NotifyCanExecuteChanged();
}
partial void OnWorktreeHeadCommitChanged(string? value) =>
@@ -1034,6 +1038,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Task != null && _worker.IsConnected && IsWaitingForReview
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
public bool CanSubmitForReview =>
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath);
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private async System.Threading.Tasks.Task SubmitForReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
}
}
[RelayCommand(CanExecute = nameof(HasReviewFeedback))]
private async System.Threading.Tasks.Task RejectReviewAsync()
{
@@ -17,6 +17,9 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
{
public string? TaskId { get; }
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
[ObservableProperty] private string _displayTitle;
public InteractiveTerminalViewModel Terminal { get; } = new();
@@ -27,6 +30,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
/// <summary>Raised when the user submits this task's hand-driven work for review; the host
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.</summary>
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
@@ -50,6 +57,14 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
[RelayCommand]
private void Close() => CloseRequested?.Invoke(this);
private bool CanSubmitForReview() => IsTaskBased;
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private void SubmitForReview()
{
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
@@ -248,6 +248,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
@@ -287,6 +288,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
@@ -311,6 +313,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
@@ -321,6 +324,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
private async void OnPaneSubmitForReview(string taskId)
{
try
{
await _worker.SubmitTaskForReviewAsync(taskId);
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane)
CloseConPtySession(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
}
}
private void CloseConPtySession(ConPtyPaneViewModel pane)
{
if (!ConPtySessions.Contains(pane)) return;
@@ -372,6 +372,17 @@
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</StackPanel>
<!-- Submit an interactive (ConPTY) session for review: an Idle/Failed task that
still has a worktree. Commits the worktree, then moves it to WaitingForReview. -->
<StackPanel Spacing="8" IsVisible="{Binding CanSubmitForReview}">
<Border Height="1" Background="{DynamicResource LineBrush}" />
<TextBlock Classes="meta" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
Text="Worked on this by hand? Submit the worktree for review to merge it." />
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
Command="{Binding SubmitForReviewCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -14,13 +14,18 @@
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3">
<Grid ColumnDefinitions="*,Auto">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding DisplayTitle}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center" Margin="4,0,0,0" />
<Button Grid.Column="1" Classes="title-ctrl"
<Button Grid.Column="1" Classes="btn" Margin="0,0,6,0"
IsVisible="{Binding IsTaskBased}"
Content="{loc:Tr missionControl.submitForReview}"
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
Command="{Binding SubmitForReviewCommand}" />
<Button Grid.Column="2" Classes="title-ctrl"
Command="{Binding CloseCommand}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>