diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index d34c38f0..062ff962 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -277,6 +277,7 @@ "settings": "Einstellungen", "queue": "Warteschlange", "blocked": "Blockiert", + "running": "Läuft", "focusMode": "Fokus", "overviewMode": "Übersicht", "closeSession": "Sitzung schließen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8bc0e488..df14b06a 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -277,6 +277,7 @@ "settings": "Settings", "queue": "Queue", "blocked": "Blocked", + "running": "Running", "focusMode": "Focus", "overviewMode": "Overview", "closeSession": "Close session", diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 650bf90b..d7a751dd 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -81,7 +81,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable ConPtySessions.CollectionChanged += OnConPtySessionsChanged; Panes.CollectionChanged += OnPanesChanged; - _onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; + _onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); }; _worker.TaskStartedEvent += _onTaskStarted; _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync(); @@ -90,10 +90,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onTaskUpdated = taskId => _ = RefreshQueueAsync(); _worker.TaskUpdatedEvent += _onTaskUpdated; - _onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; + _onConnectionRestored = () => { _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; - SeedActive(); _ = RefreshQueueAsync(); } @@ -103,19 +102,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable { 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 }) + .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued + || t.Status == ClaudeDo.Data.Models.TaskStatus.Running) + .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1) + .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status }) .ToListAsync(); Queued.Clear(); foreach (var r in rows) + { + var id = r.Id; Queued.Add(new QueuedTaskViewModel { - Id = r.Id, + Id = id, Title = r.Title ?? string.Empty, IsBlocked = r.BlockedByTaskId != null, + IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running, + OpenInAppCommand = new RelayCommand(() => _openInApp?.Invoke(id)), }); + } OnPropertyChanged(nameof(HasQueued)); } catch { /* best-effort queue refresh */ } @@ -141,13 +147,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable await RefreshQueueAsync(); } - private void SeedActive() + // Kept intentionally — unused since auto-seeding was disabled (2026-07-29). + internal void SeedActive() { foreach (var a in _worker.GetActiveTasks()) EnsureMonitor(a.TaskId); } - private void EnsureMonitor(string taskId) + // Kept intentionally — unused since auto-seeding was disabled (2026-07-29). + internal void EnsureMonitor(string taskId) { if (string.IsNullOrEmpty(taskId)) return; if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return; @@ -428,10 +436,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } -/// Read-only display row for a queued task in the Mission Control side strip. +/// Read-only display row for a queued or running 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; } + public bool IsRunning { get; init; } + public IRelayCommand? OpenInAppCommand { get; init; } } diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 0292abeb..779f7dfe 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -73,21 +73,35 @@ - - - - - - + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index e82a42d3..f4552136 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -49,8 +49,10 @@ public class MissionControlViewModelTests : IDisposable private MissionControlViewModel BuildVm(StubWorkerClient worker) => new MissionControlViewModel(new TestDbFactory(NewContext), worker); + // ── acceptance criterion (a): TaskStarted must NOT add a pane ────────────── + [Fact] - public void TwoStarts_CreateTwoMonitors_ColumnCountTwo() + public void TaskStarted_DoesNotAddPane() { var worker = new FakeWorker(); using var vm = BuildVm(worker); @@ -58,18 +60,140 @@ public class MissionControlViewModelTests : IDisposable worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); + Assert.Empty(vm.Monitors); + Assert.Empty(vm.Panes); + } + + [Fact] + public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks() + { + var worker = new SeededFakeWorker(); + using var vm = BuildVm(worker); + + Assert.Empty(vm.Monitors); + Assert.Empty(vm.Panes); + } + + private sealed class SeededFakeWorker : StubWorkerClient + { + public override IReadOnlyList GetActiveTasks() + => new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) }; + } + + // ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─ + + [Fact] + public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + // Running task has higher SortOrder — must still appear first + db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 }); + db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + await db.SaveChangesAsync(); + } + + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + await vm.RefreshQueueAsync(); + + Assert.True(vm.HasQueued); + Assert.Equal(2, vm.Queued.Count); + + Assert.Equal("r1", vm.Queued[0].Id); + Assert.True(vm.Queued[0].IsRunning); + + Assert.Equal("q1", vm.Queued[1].Id); + Assert.False(vm.Queued[1].IsRunning); + } + + [Fact] + public async Task HasQueued_TrueWhenOnlyRunningTasksExist() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + await db.SaveChangesAsync(); + } + + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + await vm.RefreshQueueAsync(); + + Assert.True(vm.HasQueued); + } + + // ── acceptance criterion (c): row click invokes OpenInApp ────────────────── + + [Fact] + public async Task Row_Click_InvokesOpenInApp() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + await db.SaveChangesAsync(); + } + + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + string? opened = null; + vm.OpenInApp = id => opened = id; + + await vm.RefreshQueueAsync(); + vm.Queued[0].OpenInAppCommand!.Execute(null); + + Assert.Equal("q1", opened); + } + + [Fact] + public async Task Row_Click_RunningTask_InvokesOpenInApp() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + await db.SaveChangesAsync(); + } + + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + string? opened = null; + vm.OpenInApp = id => opened = id; + + await vm.RefreshQueueAsync(); + vm.Queued[0].OpenInAppCommand!.Execute(null); + + Assert.Equal("r1", opened); + } + + // ── monitor machinery tests (EnsureMonitor still works when called directly) ─ + + [Fact] + public void EnsureMonitor_TwoCalls_CreateTwoMonitors_ColumnCountTwo() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + vm.EnsureMonitor("t1"); + vm.EnsureMonitor("t2"); + Assert.Equal(2, vm.Monitors.Count); Assert.Equal(2, vm.ColumnCount); } [Fact] - public void DuplicateStart_DoesNotAddSecondMonitor() + public void EnsureMonitor_DuplicateCall_DoesNotAddSecondMonitor() { var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); + vm.EnsureMonitor("t1"); Assert.Equal(1, vm.Monitors.Count); } @@ -80,7 +204,7 @@ public class MissionControlViewModelTests : IDisposable var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); Assert.Equal(1, vm.Monitors.Count); @@ -93,8 +217,8 @@ public class MissionControlViewModelTests : IDisposable var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); - worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); + vm.EnsureMonitor("t1"); + vm.EnsureMonitor("t2"); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); vm.ClearFinishedCommand.Execute(null); @@ -104,22 +228,6 @@ public class MissionControlViewModelTests : IDisposable 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) }; - } - [Fact] public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook() { @@ -129,7 +237,7 @@ public class MissionControlViewModelTests : IDisposable string? revealed = null; vm.OpenInApp = id => revealed = id; - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); vm.Monitors[0].OpenInAppCommand.Execute(null); @@ -145,7 +253,7 @@ public class MissionControlViewModelTests : IDisposable Action? reDock = null; vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; }; - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); var monitor = vm.Monitors[0]; monitor.DetachCommand.Execute(null); @@ -162,7 +270,7 @@ public class MissionControlViewModelTests : IDisposable { var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow); Assert.True(vm.Monitors[0].IsWaitingForReview); @@ -177,7 +285,7 @@ public class MissionControlViewModelTests : IDisposable using var vm = BuildVm(worker); Action? reDock = null; vm.ShowDetached = (m, rd) => reDock = rd; - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); var monitor = vm.Monitors[0]; monitor.DetachCommand.Execute(null); @@ -195,7 +303,7 @@ public class MissionControlViewModelTests : IDisposable var worker = new FakeWorker(); using var vm = BuildVm(worker); vm.ShowDetached = (m, rd) => { }; - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); var monitor = vm.Monitors[0]; var closeRequested = false; @@ -210,9 +318,9 @@ public class MissionControlViewModelTests : IDisposable { var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("s1", "t1", DateTime.UtcNow); - worker.RaiseTaskStarted("s2", "t2", DateTime.UtcNow); - worker.RaiseTaskStarted("s3", "t3", DateTime.UtcNow); + vm.EnsureMonitor("t1"); + vm.EnsureMonitor("t2"); + vm.EnsureMonitor("t3"); 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()); @@ -320,7 +428,7 @@ public class MissionControlViewModelTests : IDisposable var worker = new FakeWorker(); using var vm = BuildVm(worker); - worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + vm.EnsureMonitor("t1"); await vm.OpenConPtySessionAsync("t2"); Assert.Equal(2, vm.Panes.Count);