feat(mission-control): show running Worker operations in a Running now section

Registry of active verify/merge/rebase/worktree-creation operations in the Worker,
GetActiveOperations snapshot + OperationStarted/OperationEnded hub events, and a
Running now section in Mission Control with ticking elapsed time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-01 12:08:43 +02:00
co-authored by Claude Sonnet 5.5
parent 488c28a9d5
commit 2dbec543df
18 changed files with 539 additions and 18 deletions
@@ -59,6 +59,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// total.</summary>
event Action<string, string, int, int>? OperationProgressEvent;
/// <summary>A Worker operation (verify, merge, rebase, worktree creation) began.</summary>
event Action<RunningOperationDto>? OperationStartedEvent;
/// <summary>The operation with this id finished, failed or threw.</summary>
event Action<string>? OperationEndedEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
@@ -208,6 +213,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
/// <summary>Operations running on the Worker right now — the state to start from before applying started/ended events.</summary>
Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id);
+15
View File
@@ -69,6 +69,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<RunningOperationDto>? OperationStartedEvent;
public event Action<string>? OperationEndedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
@@ -203,6 +205,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
});
_hub.On<RunningOperationDto>("OperationStarted", op =>
{
Dispatcher.UIThread.Post(() => OperationStartedEvent?.Invoke(op));
});
_hub.On<string>("OperationEnded", id =>
{
Dispatcher.UIThread.Post(() => OperationEndedEvent?.Invoke(id));
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
@@ -530,6 +542,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public async Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync()
=> await TryInvokeAsync<List<RunningOperationDto>>("GetActiveOperations") ?? new List<RunningOperationDto>();
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
@@ -23,6 +23,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<RunningOperationDto> _onOperationStarted;
private readonly Action<string> _onOperationEnded;
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
private readonly Action<string, string> _onHandlerUsageWarning;
@@ -78,6 +80,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0;
/// <summary>Worker operations running right now (verify, merge, rebase, worktree creation), oldest first.</summary>
public ObservableCollection<RunningOperationViewModel> RunningOperations { get; } = new();
public bool HasRunningOperations => RunningOperations.Count > 0;
/// <summary>The activity strip is shown while it has queue rows or running operations.</summary>
public bool HasActivity => HasQueued || HasRunningOperations;
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
@@ -118,9 +127,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
_worker.TaskUpdatedEvent += _onTaskUpdated;
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_onConnectionRestored = () => { _ = RefreshQueueAsync(); _ = RefreshOperationsAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored;
_onOperationStarted = AddOperation;
_worker.OperationStartedEvent += _onOperationStarted;
_onOperationEnded = RemoveOperation;
_worker.OperationEndedEvent += _onOperationEnded;
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
@@ -133,9 +147,52 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred;
_ = RefreshQueueAsync();
_ = RefreshOperationsAsync();
_ = RefreshSessionTargetsAsync();
}
/// <summary>Replaces the running-operations list with the Worker's snapshot (open and reconnect).</summary>
internal async System.Threading.Tasks.Task RefreshOperationsAsync()
{
try
{
var snapshot = await _worker.GetActiveOperationsAsync();
RunningOperations.Clear();
foreach (var op in snapshot.OrderBy(o => o.StartedAt))
RunningOperations.Add(new RunningOperationViewModel(op));
NotifyOperationsChanged();
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.operationsRefreshFailed", ex.Message)); }
}
private void AddOperation(RunningOperationDto op)
{
if (RunningOperations.Any(r => r.Id == op.Id)) return;
RunningOperations.Add(new RunningOperationViewModel(op));
NotifyOperationsChanged();
}
private void RemoveOperation(string id)
{
var row = RunningOperations.FirstOrDefault(r => r.Id == id);
if (row is null) return;
RunningOperations.Remove(row);
NotifyOperationsChanged();
}
private void NotifyOperationsChanged()
{
OnPropertyChanged(nameof(HasRunningOperations));
OnPropertyChanged(nameof(HasActivity));
}
/// <summary>Refreshes every row's elapsed text; the view calls this once a second while open.</summary>
public void TickOperations(DateTime? nowUtc = null)
{
var now = nowUtc ?? DateTime.UtcNow;
foreach (var r in RunningOperations) r.Tick(now);
}
internal async System.Threading.Tasks.Task RefreshQueueAsync()
{
try
@@ -174,6 +231,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
});
}
OnPropertyChanged(nameof(HasQueued));
OnPropertyChanged(nameof(HasActivity));
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
@@ -777,6 +835,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskFinishedEvent -= _onTaskFinished;
_worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.OperationStartedEvent -= _onOperationStarted;
_worker.OperationEndedEvent -= _onOperationEnded;
_worker.HandoffRequestedEvent -= _onHandoffRequested;
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
@@ -820,6 +880,45 @@ public sealed class QueuedTaskViewModel
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One row in the "Running now" section: a Worker operation with a ticking elapsed time.</summary>
public sealed partial class RunningOperationViewModel : ObservableObject
{
private readonly DateTime _startedAtUtc;
public string Id { get; }
/// <summary>Localized operation kind ("Verify (merge)", "Rebasing", ...).</summary>
public string KindLabel { get; }
/// <summary><c>#Number Title</c> for a task operation, the list name for a batch verify.</summary>
public string Subject { get; }
[ObservableProperty] private string _elapsed;
public RunningOperationViewModel(RunningOperationDto op)
{
Id = op.Id;
_startedAtUtc = op.StartedAt;
KindLabel = op.Kind switch
{
OperationKinds.VerifyMerge => Loc.T("missionControl.opVerifyMerge"),
OperationKinds.VerifyBatch => Loc.T("missionControl.opVerifyBatch"),
OperationKinds.Merging => Loc.T("missionControl.opMerging"),
OperationKinds.Rebasing => Loc.T("missionControl.opRebasing"),
OperationKinds.CreatingWorktree => Loc.T("missionControl.opCreatingWorktree"),
_ => op.Kind,
};
Subject = op.TaskNumber is > 0 ? $"#{op.TaskNumber} {op.Title}" : op.Title;
_elapsed = Format(DateTime.UtcNow - _startedAtUtc);
}
public void Tick(DateTime nowUtc) => Elapsed = Format(nowUtc - _startedAtUtc);
private static string Format(TimeSpan t)
{
if (t < TimeSpan.Zero) t = TimeSpan.Zero;
return t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss");
}
}
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
{
@@ -185,7 +185,7 @@
</Panel>
<GridSplitter x:Name="QueueSplitter" Grid.Column="1"
IsVisible="{Binding HasQueued}"
IsVisible="{Binding HasActivity}"
Width="6"
Background="Transparent"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
@@ -194,12 +194,49 @@
<!-- Read-only queue strip — collapses when nothing is queued -->
<Border Grid.Column="2"
IsVisible="{Binding HasQueued}"
IsVisible="{Binding HasActivity}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True" Margin="10,10">
<!-- Worker operations running right now; hidden when nothing runs. -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12" Spacing="4"
IsVisible="{Binding HasRunningOperations}">
<TextBlock Classes="eyebrow"
Text="{loc:Tr missionControl.runningNow}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding RunningOperations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:RunningOperationViewModel">
<Panel Margin="0,0,0,4">
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" />
<Border Background="{DynamicResource RunningTintBrush}"
BorderBrush="{DynamicResource RunningTintBorderBrush}"
BorderThickness="1" CornerRadius="6" />
<StackPanel Margin="8,6" Spacing="2">
<TextBlock Text="{Binding Subject}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Subject}"
Foreground="{DynamicResource TextDimBrush}" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="meta"
Text="{Binding KindLabel}"
Foreground="{DynamicResource StatusRunningBrush}" />
<TextBlock Grid.Column="1" Classes="meta"
Text="{Binding Elapsed}"
Foreground="{DynamicResource TextMuteBrush}" />
</Grid>
</StackPanel>
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
IsVisible="{Binding HasQueued}"
Text="{loc:Tr missionControl.activity}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" />
@@ -40,6 +40,24 @@ public partial class MissionControlView : UserControl
OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased);
}
// Ticks the "Running now" elapsed times while the view is on screen.
private Avalonia.Threading.DispatcherTimer? _elapsedTimer;
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_elapsedTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += (_, _) => _vm?.TickOperations();
_elapsedTimer.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_elapsedTimer?.Stop();
_elapsedTimer = null;
base.OnDetachedFromVisualTree(e);
}
private const double DragThreshold = 4;
/// <summary>Tile presenter whose header was pressed; null when no tile drag is pending.</summary>
@@ -199,7 +217,7 @@ public partial class MissionControlView : UserControl
{
if (e.PropertyName == nameof(MissionControlViewModel.ColumnCount))
RebuildOverviewGrid();
else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued))
else if (e.PropertyName == nameof(MissionControlViewModel.HasActivity))
UpdateQueueColumn();
else if (e.PropertyName == nameof(MissionControlViewModel.IsFocusMode))
// The now-visible layout's hosts must re-steal their pane views from the hidden
@@ -221,7 +239,7 @@ public partial class MissionControlView : UserControl
private void UpdateQueueColumn()
{
var queueColumn = BodyGrid.ColumnDefinitions[2];
if (_vm?.HasQueued == true)
if (_vm?.HasActivity == true)
{
queueColumn.MinWidth = MinQueueWidth;
queueColumn.Width = _queueWidth;