feat(ui): add MissionControlViewModel
This commit is contained in:
@@ -158,6 +158,10 @@ sealed class Program
|
||||
sp,
|
||||
sp.GetRequiredService<INotesApi>(),
|
||||
sp.GetRequiredService<IMergeCoordinator>()));
|
||||
sc.AddSingleton<MissionControlViewModel>(sp =>
|
||||
new MissionControlViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||
{
|
||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||
|
||||
@@ -34,6 +34,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
|
||||
string? LastApproveTarget { get; }
|
||||
|
||||
IReadOnlyList<ActiveTask> GetActiveTasks();
|
||||
|
||||
Task WakeQueueAsync();
|
||||
Task RunNowAsync(string taskId);
|
||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
@@ -68,6 +69,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
public string? LastApproveTarget { get; private set; }
|
||||
|
||||
public IReadOnlyList<ActiveTask> GetActiveTasks() => ActiveTasks.ToList();
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
|
||||
104
src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
Normal file
104
src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
private readonly Action _onConnectionRestored;
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
|
||||
[ObservableProperty] private int _columnCount = 1;
|
||||
|
||||
public bool HasMonitors => Monitors.Count > 0;
|
||||
|
||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => EnsureMonitor(taskId);
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
|
||||
_onConnectionRestored = SeedActive;
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
SeedActive();
|
||||
}
|
||||
|
||||
private void SeedActive()
|
||||
{
|
||||
foreach (var a in _worker.GetActiveTasks())
|
||||
EnsureMonitor(a.TaskId);
|
||||
}
|
||||
|
||||
private void EnsureMonitor(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return;
|
||||
|
||||
var monitor = new TaskMonitorViewModel(_dbFactory, _worker);
|
||||
monitor.SetTaskId(taskId);
|
||||
Monitors.Add(monitor);
|
||||
_ = HydrateAsync(monitor, taskId);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task HydrateAsync(TaskMonitorViewModel monitor, string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || monitor.SubscribedTaskId != taskId) return;
|
||||
monitor.ApplyState(entity.Status);
|
||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||
monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||
await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort hydrate */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearFinished()
|
||||
{
|
||||
foreach (var m in Monitors.Where(m => m.IsDone || m.IsFailed || m.IsCancelled).ToList())
|
||||
{
|
||||
Monitors.Remove(m);
|
||||
m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Monitors.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
Monitors.Clear();
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,14 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public int ClearMyDayCalls { get; private set; }
|
||||
public int RunDailyPrepNowCalls { get; private set; }
|
||||
|
||||
public virtual IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
|
||||
|
||||
public void RaiseTaskStarted(string slot, string taskId, DateTime startedAt) => TaskStartedEvent?.Invoke(slot, taskId, startedAt);
|
||||
public void RaiseTaskFinished(string slot, string taskId, string status, DateTime finishedAt) => TaskFinishedEvent?.Invoke(slot, taskId, status, finishedAt);
|
||||
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||
public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke();
|
||||
|
||||
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||
public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class MissionControlViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public MissionControlViewModelTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mc_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient { }
|
||||
|
||||
private MissionControlViewModel BuildVm(StubWorkerClient worker)
|
||||
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker);
|
||||
|
||||
[Fact]
|
||||
public void TwoStarts_CreateTwoMonitors_ColumnCountTwo()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(2, vm.Monitors.Count);
|
||||
Assert.Equal(2, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateStart_DoesNotAddSecondMonitor()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_KeepsPane_AndFlipsState()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
Assert.True(vm.Monitors[0].IsDone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearFinished_RemovesTerminalMonitors()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
|
||||
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
|
||||
|
||||
vm.ClearFinishedCommand.Execute(null);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
Assert.Equal("t2", vm.Monitors[0].SubscribedTaskId);
|
||||
Assert.Equal(1, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedsFromActiveTasksOnConstruction()
|
||||
{
|
||||
var worker = new SeededFakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
Assert.Equal("seed1", vm.Monitors[0].SubscribedTaskId);
|
||||
}
|
||||
|
||||
private sealed class SeededFakeWorker : StubWorkerClient
|
||||
{
|
||||
public override IReadOnlyList<ActiveTask> GetActiveTasks()
|
||||
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
||||
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
|
||||
public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
|
||||
}
|
||||
|
||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user