From 9eb54a0d2faefa902e6cabda02a533f9d6aceeea Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Thu, 25 Jun 2026 17:02:43 +0200 Subject: [PATCH] feat(ui): read-only queue side strip in Mission Control --- src/ClaudeDo.Localization/locales/de.json | 4 +- src/ClaudeDo.Localization/locales/en.json | 4 +- .../ViewModels/MissionControlViewModel.cs | 51 ++++++++++++++++++- .../MissionControl/MissionControlView.axaml | 38 ++++++++++++++ .../MissionControlViewModelTests.cs | 25 +++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index b141009..adeba25 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -241,7 +241,9 @@ "windowTitle": "Mission Control", "clearFinished": "Erledigte entfernen", "empty": "Keine laufenden Aufgaben", - "settings": "Einstellungen" + "settings": "Einstellungen", + "queue": "Warteschlange", + "blocked": "Blockiert" }, "modals": { "logVisualizer": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 30e5150..ee7e35c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -241,7 +241,9 @@ "windowTitle": "Mission Control", "clearFinished": "Clear finished", "empty": "No running tasks", - "settings": "Settings" + "settings": "Settings", + "queue": "Queue", + "blocked": "Blocked" }, "modals": { "logVisualizer": { diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 2c5c2a2..3462aaf 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -16,6 +16,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient _worker; private readonly Action _onTaskStarted; + private readonly Action _onTaskFinished; + private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; public ObservableCollection Monitors { get; } = new(); @@ -42,6 +44,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public bool HasMonitors => Monitors.Count > 0; + // Read-only view of the worker queue (tasks waiting to run), shown as a side strip. + public ObservableCollection Queued { get; } = new(); + public bool HasQueued => Queued.Count > 0; + public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker) { _dbFactory = dbFactory; @@ -49,13 +55,44 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable Monitors.CollectionChanged += OnMonitorsChanged; - _onTaskStarted = (slot, taskId, startedAt) => EnsureMonitor(taskId); + _onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; _worker.TaskStartedEvent += _onTaskStarted; - _onConnectionRestored = SeedActive; + _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync(); + _worker.TaskFinishedEvent += _onTaskFinished; + + _onTaskUpdated = taskId => _ = RefreshQueueAsync(); + _worker.TaskUpdatedEvent += _onTaskUpdated; + + _onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; SeedActive(); + _ = RefreshQueueAsync(); + } + + internal async System.Threading.Tasks.Task RefreshQueueAsync() + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var rows = await ctx.Tasks.AsNoTracking() + .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .Select(t => new { t.Id, t.Title, t.BlockedByTaskId }) + .ToListAsync(); + + Queued.Clear(); + foreach (var r in rows) + Queued.Add(new QueuedTaskViewModel + { + Id = r.Id, + Title = r.Title ?? string.Empty, + IsBlocked = r.BlockedByTaskId != null, + }); + OnPropertyChanged(nameof(HasQueued)); + } + catch { /* best-effort queue refresh */ } } private void SeedActive() @@ -144,9 +181,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public void Dispose() { _worker.TaskStartedEvent -= _onTaskStarted; + _worker.TaskFinishedEvent -= _onTaskFinished; + _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; Monitors.CollectionChanged -= OnMonitorsChanged; foreach (var m in Monitors) m.Dispose(); Monitors.Clear(); } } + +/// Read-only display row for a queued task in the Mission Control side strip. +public sealed class QueuedTaskViewModel +{ + public required string Id { get; init; } + public required string Title { get; init; } + public bool IsBlocked { get; init; } +} diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 7ea7754..465870a 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -33,6 +33,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 54c2a89..d1cf980 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -1,10 +1,12 @@ using System.Linq; using ClaudeDo.Data; +using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; @@ -214,4 +216,27 @@ public class MissionControlViewModelTests : IDisposable vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray()); } + + [Fact] + public async Task Queue_ReflectsQueuedTasks_InSortOrder() + { + await SeedQueueAsync(); + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + await vm.RefreshQueueAsync(); + + Assert.True(vm.HasQueued); + Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray()); + } + + private async Task SeedQueueAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity { Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 }); + db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + db.Tasks.Add(new TaskEntity { Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 }); + await db.SaveChangesAsync(); + } }