feat(ui): detach a monitor into its own window

This commit is contained in:
Mika Kuns
2026-06-25 15:30:37 +02:00
parent 4e2798b400
commit 5f6e7480f2
11 changed files with 85 additions and 0 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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);
} }

View File

@@ -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()
{ {

View File

@@ -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;

View File

@@ -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

View File

@@ -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}" />

View 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>

View File

@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.MissionControl;
public partial class TaskMonitorWindow : Window
{
public TaskMonitorWindow() => InitializeComponent();
}

View File

@@ -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>();

View File

@@ -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]);
}
} }