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:
co-authored by
Claude Sonnet 5.5
parent
488c28a9d5
commit
2dbec543df
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user