From fbcffce79c672bb563e7cc9c3ea168c9c7fab5da Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Thu, 25 Jun 2026 16:24:21 +0200 Subject: [PATCH] feat(ui): mission control detach/redock toggle, clear review panes, reorder helper --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Islands/TaskMonitorViewModel.cs | 19 +++++- .../ViewModels/MissionControlViewModel.cs | 13 +++- .../MissionControl/TaskMonitorWindow.axaml.cs | 15 +++++ .../MissionControlViewModelTests.cs | 62 +++++++++++++++++++ .../ViewModels/TaskMonitorViewModelTests.cs | 21 +++++++ 7 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 5d83afe..04d0bec 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -237,6 +237,7 @@ "openInApp": "In App öffnen", "cancel": "Abbrechen", "detach": "Abdocken", + "redock": "Andocken", "windowTitle": "Mission Control", "clearFinished": "Erledigte entfernen", "empty": "Keine laufenden Aufgaben" diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 231de52..7e73a0c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -237,6 +237,7 @@ "openInApp": "Open in app", "cancel": "Cancel", "detach": "Detach", + "redock": "Re-dock", "windowTitle": "Mission Control", "clearFinished": "Clear finished", "empty": "No running tasks" diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index 752a65c..504bdd0 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -59,8 +59,11 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoadblockCard))] + [NotifyPropertyChangedFor(nameof(HasRoadblock))] private string? _roadblocks; + public bool HasRoadblock => !string.IsNullOrWhiteSpace(Roadblocks); + public bool ShowRoadblockCard => !string.IsNullOrWhiteSpace(Roadblocks) && (IsWaitingForReview || IsDone || IsFailed || IsCancelled); @@ -139,6 +142,16 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable Roadblocks = null; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DetachTooltip))] + private bool _isDetached; + + // Localized tooltip for the detach/re-dock toggle button. + public string DetachTooltip => Loc.T(IsDetached ? "missionControl.redock" : "missionControl.detach"); + + // Set by the detached window so the re-dock action can close it. + public Action? CloseWindowRequested { get; set; } + // Set by the host (e.g. Mission Control) to navigate the main app to this task. public Action? OpenInAppRequested { get; set; } @@ -146,7 +159,11 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable public Action? DetachRequested { get; set; } [RelayCommand] - private void Detach() => DetachRequested?.Invoke(this); + private void Detach() + { + if (IsDetached) CloseWindowRequested?.Invoke(); // re-dock: close the detached window + else DetachRequested?.Invoke(this); // detach: pop out to its own window + } [RelayCommand] private void OpenInApp() diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index f339b6e..fece879 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -77,12 +77,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private void Detach(TaskMonitorViewModel monitor) { if (!Monitors.Contains(monitor)) return; + monitor.IsDetached = true; Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming ShowDetached?.Invoke(monitor, () => ReDock(monitor)); } private void ReDock(TaskMonitorViewModel monitor) { + monitor.IsDetached = false; if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null) Monitors.Add(monitor); // back into the grid } @@ -106,13 +108,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable [RelayCommand] private void ClearFinished() { - foreach (var m in Monitors.Where(m => m.IsDone || m.IsFailed || m.IsCancelled).ToList()) + foreach (var m in Monitors.Where(m => m.IsDone || m.IsFailed || m.IsCancelled || m.IsWaitingForReview).ToList()) { Monitors.Remove(m); m.Dispose(); } } + public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target) + { + if (ReferenceEquals(dragged, target)) return; + var from = Monitors.IndexOf(dragged); + var to = Monitors.IndexOf(target); + if (from < 0 || to < 0) return; + Monitors.Move(from, to); + } + private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e) { ColumnCount = Monitors.Count switch diff --git a/src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml.cs index 807f19f..0448a10 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Controls; namespace ClaudeDo.Ui.Views.MissionControl; @@ -5,4 +6,18 @@ namespace ClaudeDo.Ui.Views.MissionControl; public partial class TaskMonitorWindow : Window { public TaskMonitorWindow() => InitializeComponent(); + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + if (DataContext is ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel vm) + vm.CloseWindowRequested = Close; + } + + protected override void OnClosed(EventArgs e) + { + if (DataContext is ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel vm) + vm.CloseWindowRequested = null; + base.OnClosed(e); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 26158b1..54c2a89 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using ClaudeDo.Data; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; @@ -152,4 +153,65 @@ public class MissionControlViewModelTests : IDisposable Assert.Single(vm.Monitors); Assert.Same(monitor, vm.Monitors[0]); } + + [Fact] + public void ClearFinished_AlsoRemoves_WaitingForReview() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow); + + Assert.True(vm.Monitors[0].IsWaitingForReview); + vm.ClearFinishedCommand.Execute(null); + Assert.Empty(vm.Monitors); + } + + [Fact] + public void Detach_SetsIsDetached_AndReDockClearsIt() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + Action? reDock = null; + vm.ShowDetached = (m, rd) => reDock = rd; + worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + var monitor = vm.Monitors[0]; + + monitor.DetachCommand.Execute(null); + Assert.True(monitor.IsDetached); + Assert.Empty(vm.Monitors); + + reDock!.Invoke(); + Assert.False(monitor.IsDetached); + Assert.Single(vm.Monitors); + } + + [Fact] + public void DetachCommand_WhenDetached_RequestsWindowClose() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + vm.ShowDetached = (m, rd) => { }; + worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); + var monitor = vm.Monitors[0]; + + var closeRequested = false; + monitor.DetachCommand.Execute(null); // detach (IsDetached = true) + monitor.CloseWindowRequested = () => closeRequested = true; + monitor.DetachCommand.Execute(null); // now acts as re-dock + Assert.True(closeRequested); + } + + [Fact] + public void MoveMonitor_ReordersCollection() + { + 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.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()); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs index d9f9f02..23f22a4 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs @@ -99,4 +99,25 @@ public class TaskMonitorViewModelTests : IDisposable Assert.Equal("Summary text", vm.SessionOutcome); Assert.Equal("- something broke", vm.Roadblocks); } + + [Fact] + public void HasRoadblock_TrueAfterRoadblockOutcome() + { + var worker = new FakeWorker(); + using var vm = Build(worker); + vm.ApplyOutcome("Summary\n\nRoadblocks reported during the run:\n- broke", errorFallback: null); + Assert.True(vm.HasRoadblock); + } + + [Fact] + public void Detach_WhenNotDetached_InvokesDetachRequested() + { + var worker = new FakeWorker(); + using var vm = Build(worker); + vm.SetTaskId("t1"); + TaskMonitorViewModel? requested = null; + vm.DetachRequested = m => requested = m; + vm.DetachCommand.Execute(null); + Assert.Same(vm, requested); + } }