feat(ui): add MissionControlViewModel
This commit is contained in:
@@ -158,6 +158,10 @@ sealed class Program
|
|||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<INotesApi>(),
|
sp.GetRequiredService<INotesApi>(),
|
||||||
sp.GetRequiredService<IMergeCoordinator>()));
|
sp.GetRequiredService<IMergeCoordinator>()));
|
||||||
|
sc.AddSingleton<MissionControlViewModel>(sp =>
|
||||||
|
new MissionControlViewModel(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
{
|
{
|
||||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
|
|
||||||
string? LastApproveTarget { get; }
|
string? LastApproveTarget { get; }
|
||||||
|
|
||||||
|
IReadOnlyList<ActiveTask> GetActiveTasks();
|
||||||
|
|
||||||
Task WakeQueueAsync();
|
Task WakeQueueAsync();
|
||||||
Task RunNowAsync(string taskId);
|
Task RunNowAsync(string taskId);
|
||||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -68,6 +69,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public string? LastApproveTarget { get; private set; }
|
public string? LastApproveTarget { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<ActiveTask> GetActiveTasks() => ActiveTasks.ToList();
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl)
|
||||||
{
|
{
|
||||||
_hub = new HubConnectionBuilder()
|
_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 ClearMyDayCalls { get; private set; }
|
||||||
public int RunDailyPrepNowCalls { 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 RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||||
public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok);
|
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 SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||||
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
||||||
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
|
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
|
||||||
|
public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user