feat(ui): detach a monitor into its own window
This commit is contained in:
@@ -236,6 +236,7 @@
|
|||||||
"missionControl": {
|
"missionControl": {
|
||||||
"openInApp": "In App öffnen",
|
"openInApp": "In App öffnen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"detach": "Abdocken",
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Erledigte entfernen",
|
"clearFinished": "Erledigte entfernen",
|
||||||
"empty": "Keine laufenden Aufgaben"
|
"empty": "Keine laufenden Aufgaben"
|
||||||
|
|||||||
@@ -236,6 +236,7 @@
|
|||||||
"missionControl": {
|
"missionControl": {
|
||||||
"openInApp": "Open in app",
|
"openInApp": "Open in app",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"detach": "Detach",
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Clear finished",
|
"clearFinished": "Clear finished",
|
||||||
"empty": "No running tasks"
|
"empty": "No running tasks"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
@@ -32,4 +34,7 @@ public interface IDialogService
|
|||||||
|
|
||||||
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
|
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
|
||||||
void ShowMissionControl(MissionControlViewModel vm);
|
void ShowMissionControl(MissionControlViewModel vm);
|
||||||
|
|
||||||
|
/// <summary>Show a detached monitor in its own window; <paramref name="onClosed"/> re-docks it when that window closes.</summary>
|
||||||
|
void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
// 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; }
|
||||||
|
|
||||||
|
// Set by the host (Mission Control) to pop this monitor out into its own window.
|
||||||
|
public Action<TaskMonitorViewModel>? DetachRequested { get; set; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Detach() => DetachRequested?.Invoke(this);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenInApp()
|
private void OpenInApp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
MissionControl = missionControl;
|
MissionControl = missionControl;
|
||||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||||
|
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
||||||
_updateCheck = updateCheck;
|
_updateCheck = updateCheck;
|
||||||
_installerLocator = installerLocator;
|
_installerLocator = installerLocator;
|
||||||
_workerLocator = workerLocator;
|
_workerLocator = workerLocator;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View-layer seam: show a detached monitor in its own window. Second arg is the re-dock callback
|
||||||
|
// invoked when that window closes.
|
||||||
|
public Action<TaskMonitorViewModel, Action>? ShowDetached { get; set; }
|
||||||
|
|
||||||
public bool HasMonitors => Monitors.Count > 0;
|
public bool HasMonitors => Monitors.Count > 0;
|
||||||
|
|
||||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||||
@@ -65,10 +69,24 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
var monitor = new TaskMonitorViewModel(_dbFactory, _worker);
|
var monitor = new TaskMonitorViewModel(_dbFactory, _worker);
|
||||||
monitor.SetTaskId(taskId);
|
monitor.SetTaskId(taskId);
|
||||||
monitor.OpenInAppRequested = _openInApp;
|
monitor.OpenInAppRequested = _openInApp;
|
||||||
|
monitor.DetachRequested = Detach;
|
||||||
Monitors.Add(monitor);
|
Monitors.Add(monitor);
|
||||||
_ = HydrateAsync(monitor, taskId);
|
_ = HydrateAsync(monitor, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Detach(TaskMonitorViewModel monitor)
|
||||||
|
{
|
||||||
|
if (!Monitors.Contains(monitor)) return;
|
||||||
|
Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming
|
||||||
|
ShowDetached?.Invoke(monitor, () => ReDock(monitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReDock(TaskMonitorViewModel monitor)
|
||||||
|
{
|
||||||
|
if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null)
|
||||||
|
Monitors.Add(monitor); // back into the grid
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task HydrateAsync(TaskMonitorViewModel monitor, string taskId)
|
private async System.Threading.Tasks.Task HydrateAsync(TaskMonitorViewModel monitor, string taskId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="btn" Content="{loc:Tr missionControl.openInApp}"
|
<Button Classes="btn" Content="{loc:Tr missionControl.openInApp}"
|
||||||
Command="{Binding OpenInAppCommand}" />
|
Command="{Binding OpenInAppCommand}" />
|
||||||
|
<Button Classes="btn" Content="{loc:Tr missionControl.detach}"
|
||||||
|
Command="{Binding DetachCommand}" />
|
||||||
<Button Classes="btn" Content="{loc:Tr missionControl.cancel}"
|
<Button Classes="btn" Content="{loc:Tr missionControl.cancel}"
|
||||||
Command="{Binding CancelTaskCommand}"
|
Command="{Binding CancelTaskCommand}"
|
||||||
IsVisible="{Binding IsRunning}" />
|
IsVisible="{Binding IsRunning}" />
|
||||||
|
|||||||
12
src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml
Normal file
12
src/ClaudeDo.Ui/Views/MissionControl/TaskMonitorWindow.axaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
|
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.MissionControl.TaskMonitorWindow"
|
||||||
|
x:DataType="vm:TaskMonitorViewModel"
|
||||||
|
Title="{Binding DisplayTitle}"
|
||||||
|
Width="560" Height="620" MinWidth="360" MinHeight="320"
|
||||||
|
Background="{DynamicResource VoidBrush}"
|
||||||
|
Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico">
|
||||||
|
<mc:MonitorPaneView />
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||||
|
|
||||||
|
public partial class TaskMonitorWindow : Window
|
||||||
|
{
|
||||||
|
public TaskMonitorWindow() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -6,6 +7,7 @@ using Avalonia.Media;
|
|||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using ClaudeDo.Ui.Views.Conflicts;
|
using ClaudeDo.Ui.Views.Conflicts;
|
||||||
using ClaudeDo.Ui.Views.MissionControl;
|
using ClaudeDo.Ui.Views.MissionControl;
|
||||||
@@ -121,6 +123,13 @@ public sealed class WindowDialogService : IDialogService
|
|||||||
_missionControl.Activate(); // bring to front / focus
|
_missionControl.Activate(); // bring to front / focus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed)
|
||||||
|
{
|
||||||
|
var win = new TaskMonitorWindow { DataContext = monitor };
|
||||||
|
win.Closed += (_, _) => onClosed(); // closing re-docks into the grid
|
||||||
|
win.Show(); // modeless, independent
|
||||||
|
}
|
||||||
|
|
||||||
public Task<bool> ConfirmAsync(string message)
|
public Task<bool> ConfirmAsync(string message)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<bool>();
|
var tcs = new TaskCompletionSource<bool>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -130,4 +131,25 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal("t1", revealed);
|
Assert.Equal("t1", revealed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detach_RemovesFromGrid_ThenReDockRestores()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
TaskMonitorViewModel? detached = null;
|
||||||
|
Action? reDock = null;
|
||||||
|
vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; };
|
||||||
|
|
||||||
|
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||||
|
var monitor = vm.Monitors[0];
|
||||||
|
|
||||||
|
monitor.DetachCommand.Execute(null);
|
||||||
|
Assert.Empty(vm.Monitors);
|
||||||
|
Assert.Same(monitor, detached);
|
||||||
|
|
||||||
|
reDock!.Invoke();
|
||||||
|
Assert.Single(vm.Monitors);
|
||||||
|
Assert.Same(monitor, vm.Monitors[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user