feat(ui): mission control detach/redock toggle, clear review panes, reorder helper
This commit is contained in:
@@ -237,6 +237,7 @@
|
|||||||
"openInApp": "In App öffnen",
|
"openInApp": "In App öffnen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"detach": "Abdocken",
|
"detach": "Abdocken",
|
||||||
|
"redock": "Andocken",
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Erledigte entfernen",
|
"clearFinished": "Erledigte entfernen",
|
||||||
"empty": "Keine laufenden Aufgaben"
|
"empty": "Keine laufenden Aufgaben"
|
||||||
|
|||||||
@@ -237,6 +237,7 @@
|
|||||||
"openInApp": "Open in app",
|
"openInApp": "Open in app",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"detach": "Detach",
|
"detach": "Detach",
|
||||||
|
"redock": "Re-dock",
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Clear finished",
|
"clearFinished": "Clear finished",
|
||||||
"empty": "No running tasks"
|
"empty": "No running tasks"
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
|
||||||
private string? _roadblocks;
|
private string? _roadblocks;
|
||||||
|
|
||||||
|
public bool HasRoadblock => !string.IsNullOrWhiteSpace(Roadblocks);
|
||||||
|
|
||||||
public bool ShowRoadblockCard =>
|
public bool ShowRoadblockCard =>
|
||||||
!string.IsNullOrWhiteSpace(Roadblocks)
|
!string.IsNullOrWhiteSpace(Roadblocks)
|
||||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||||
@@ -139,6 +142,16 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
Roadblocks = null;
|
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.
|
// Set by the host (e.g. Mission Control) to navigate the main app to this task.
|
||||||
public Action<string>? OpenInAppRequested { get; set; }
|
public Action<string>? OpenInAppRequested { get; set; }
|
||||||
|
|
||||||
@@ -146,7 +159,11 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
public Action<TaskMonitorViewModel>? DetachRequested { get; set; }
|
public Action<TaskMonitorViewModel>? DetachRequested { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[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]
|
[RelayCommand]
|
||||||
private void OpenInApp()
|
private void OpenInApp()
|
||||||
|
|||||||
@@ -77,12 +77,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
private void Detach(TaskMonitorViewModel monitor)
|
private void Detach(TaskMonitorViewModel monitor)
|
||||||
{
|
{
|
||||||
if (!Monitors.Contains(monitor)) return;
|
if (!Monitors.Contains(monitor)) return;
|
||||||
|
monitor.IsDetached = true;
|
||||||
Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming
|
Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming
|
||||||
ShowDetached?.Invoke(monitor, () => ReDock(monitor));
|
ShowDetached?.Invoke(monitor, () => ReDock(monitor));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReDock(TaskMonitorViewModel monitor)
|
private void ReDock(TaskMonitorViewModel monitor)
|
||||||
{
|
{
|
||||||
|
monitor.IsDetached = false;
|
||||||
if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null)
|
if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null)
|
||||||
Monitors.Add(monitor); // back into the grid
|
Monitors.Add(monitor); // back into the grid
|
||||||
}
|
}
|
||||||
@@ -106,13 +108,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ClearFinished()
|
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);
|
Monitors.Remove(m);
|
||||||
m.Dispose();
|
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)
|
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
ColumnCount = Monitors.Count switch
|
ColumnCount = Monitors.Count switch
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||||
@@ -5,4 +6,18 @@ namespace ClaudeDo.Ui.Views.MissionControl;
|
|||||||
public partial class TaskMonitorWindow : Window
|
public partial class TaskMonitorWindow : Window
|
||||||
{
|
{
|
||||||
public TaskMonitorWindow() => InitializeComponent();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
@@ -152,4 +153,65 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Single(vm.Monitors);
|
Assert.Single(vm.Monitors);
|
||||||
Assert.Same(monitor, vm.Monitors[0]);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,4 +99,25 @@ public class TaskMonitorViewModelTests : IDisposable
|
|||||||
Assert.Equal("Summary text", vm.SessionOutcome);
|
Assert.Equal("Summary text", vm.SessionOutcome);
|
||||||
Assert.Equal("- something broke", vm.Roadblocks);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user