diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 822ffd8..2575faa 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -158,6 +158,10 @@ sealed class Program sp, sp.GetRequiredService(), sp.GetRequiredService())); + sc.AddSingleton(sp => + new MissionControlViewModel( + sp.GetRequiredService>(), + sp.GetRequiredService())); sc.AddSingleton(sp => { var shell = ActivatorUtilities.CreateInstance(sp); diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index ee10573..cba70e5 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -34,6 +34,8 @@ public interface IWorkerClient : INotifyPropertyChanged string? LastApproveTarget { get; } + IReadOnlyList GetActiveTasks(); + Task WakeQueueAsync(); Task RunNowAsync(string taskId); Task ContinueTaskAsync(string taskId, string followUpPrompt); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 9f88fcc..34fe2d8 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -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 GetActiveTasks() => ActiveTasks.ToList(); + public WorkerClient(string signalRUrl) { _hub = new HubConnectionBuilder() diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs new file mode 100644 index 0000000..67c3c43 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -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 _dbFactory; + private readonly IWorkerClient _worker; + private readonly Action _onTaskStarted; + private readonly Action _onConnectionRestored; + + public ObservableCollection Monitors { get; } = new(); + + [ObservableProperty] private int _columnCount = 1; + + public bool HasMonitors => Monitors.Count > 0; + + public MissionControlViewModel(IDbContextFactory 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(); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 049cf95..aa06120 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -39,6 +39,14 @@ public abstract class StubWorkerClient : IWorkerClient public int ClearMyDayCalls { get; private set; } public int RunDailyPrepNowCalls { get; private set; } + public virtual IReadOnlyList GetActiveTasks() => System.Array.Empty(); + + 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); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs new file mode 100644 index 0000000..e5b8440 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -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() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func 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 GetActiveTasks() + => new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) }; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 1aa3b11..856cfbc 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -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 GetActiveTasks() => System.Array.Empty(); } // ── Helper to build VM with pre-seeded Items ──────────────────────────────────