feat(interactive): host task-based ConPTY sessions in Command Center
Adds an 'Open ConPTY session' entry that fetches a task's launch spec and hosts an embedded ConPTY terminal as a Mission Control pane, coexisting with the streamed-log monitor panes (streaming stack untouched). Introduces IMissionControlPane + ConPtyPaneViewModel, a non-destructive Panes mirror (Monitors prefix + ConPtySessions suffix) so unrelated monitor churn never tears down a live terminal, and a grid<->tabs layout toggle. Launch failures surface via the footer error strip.
This commit is contained in:
@@ -129,6 +129,7 @@
|
||||
"ctxMarkDone": "Erledigt",
|
||||
"ctxMarkCancelled": "Abgebrochen",
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
|
||||
"ctxPickUpInTerminal": "Im Terminal fortsetzen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
@@ -267,6 +268,10 @@
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung schließen",
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
"placeholder": "Antwort eingeben…",
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"ctxMarkDone": "Done",
|
||||
"ctxMarkCancelled": "Cancelled",
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenConPtySession": "Open ConPTY session",
|
||||
"ctxPickUpInTerminal": "Pick up in terminal",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
@@ -267,6 +268,10 @@
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Close session",
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
"placeholder": "Type your answer…",
|
||||
|
||||
@@ -7,11 +7,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
@@ -819,6 +819,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.runInteractiveFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
// Distinct from RunInteractivelyAsync (the old in-app streaming session) — this opens the
|
||||
// task in an embedded ConPTY terminal pane in the Command Center. The shell owns the
|
||||
// Mission Control view model, so this just raises an event for it to act on.
|
||||
public event Action<string>? OpenConPtySessionRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenConPtySession(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
OpenConPtySessionRequested?.Invoke(row.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PickUpInTerminalAsync(TaskRowViewModel? row)
|
||||
{
|
||||
|
||||
@@ -215,6 +215,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
@@ -228,6 +229,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||
Tasks.ErrorReported += FlashFooterError;
|
||||
Tasks.OpenConPtySessionRequested += taskId =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
||||
};
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Command Center pane hosting an embedded ConPTY terminal for one task's interactive Claude
|
||||
/// session (task-based only — no ad-hoc/free sessions yet). Distinct from the streamed-log
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> pane; the two coexist until
|
||||
/// the streaming interactive stack is removed.
|
||||
/// </summary>
|
||||
public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
public string TaskId { get; }
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
public InteractiveTerminalViewModel Terminal { get; } = new();
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
|
||||
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
|
||||
|
||||
public ConPtyPaneViewModel(string taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
TaskId = taskId;
|
||||
_displayTitle = displayTitle;
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
ErrorReported?.Invoke(error);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseRequested?.Invoke(this);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Terminal.Kill();
|
||||
Terminal.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for anything hosted as a pane in the Command Center — implemented by both
|
||||
/// the streamed-log <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> and the
|
||||
/// embedded ConPTY <see cref="ConPtyPaneViewModel"/> — so the layout toggle (grid/tabs) can
|
||||
/// bind one heterogeneous pane collection.
|
||||
/// </summary>
|
||||
public interface IMissionControlPane
|
||||
{
|
||||
string DisplayTitle { get; }
|
||||
}
|
||||
@@ -5,8 +5,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
@@ -23,8 +25,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
|
||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||
// review/merge/status machinery. Mirrored into Panes alongside the streamed-log Monitors.
|
||||
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
|
||||
|
||||
// Unified view of Monitors ++ ConPtySessions (in that order) so the layout toggle can
|
||||
// present one heterogeneous collection as either a grid or tabs.
|
||||
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
|
||||
|
||||
[ObservableProperty] private int _columnCount = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
||||
private bool _isFocusMode;
|
||||
|
||||
[ObservableProperty] private IMissionControlPane? _focusedPane;
|
||||
|
||||
public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");
|
||||
|
||||
/// <summary>Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell
|
||||
/// wires this into the footer error strip, same as the island view models' ErrorReported.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
private Action<string>? _openInApp;
|
||||
public Action<string>? OpenInApp
|
||||
{
|
||||
@@ -44,6 +66,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public Action? OpenSettingsRequested { get; set; }
|
||||
|
||||
public bool HasMonitors => Monitors.Count > 0;
|
||||
public bool HasPanes => Panes.Count > 0;
|
||||
|
||||
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
|
||||
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||
@@ -55,6 +78,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker = worker;
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
@@ -192,6 +217,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||
|
||||
public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target)
|
||||
{
|
||||
if (ReferenceEquals(dragged, target)) return;
|
||||
@@ -201,15 +229,106 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
Monitors.Move(from, to);
|
||||
}
|
||||
|
||||
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
||||
// Command Center pane (task-based only). A distinct entry point from RunInteractivelyAsync's
|
||||
// streaming session — the two coexist until the streaming stack is removed.
|
||||
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
|
||||
// Mirrors Monitors' add/remove/move into the front (Monitors-prefix) section of Panes.
|
||||
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Monitors.Count switch
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(e.NewStartingIndex, (TaskMonitorViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(e.OldStartingIndex);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Move:
|
||||
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
|
||||
break;
|
||||
default: // Reset (Dispose's Monitors.Clear())
|
||||
foreach (var p in Panes.OfType<TaskMonitorViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
}
|
||||
|
||||
// Mirrors ConPtySessions' add/remove into the tail (ConPtySessions-suffix) section of Panes.
|
||||
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(Monitors.Count + e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(Monitors.Count + e.OldStartingIndex);
|
||||
break;
|
||||
default: // Reset
|
||||
foreach (var p in Panes.OfType<ConPtyPaneViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Panes.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
OnPropertyChanged(nameof(HasPanes));
|
||||
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added)
|
||||
FocusedPane = added;
|
||||
else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
|
||||
FocusedPane = Panes.LastOrDefault();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -220,8 +339,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged -= OnPanesChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
Monitors.Clear();
|
||||
foreach (var c in ConPtySessions.ToList())
|
||||
{
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
Panes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxRunInteractively}"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
|
||||
Click="OnOpenConPtySessionClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxPickUpInTerminal}"
|
||||
Click="OnPickUpInTerminalClick"
|
||||
IsVisible="{Binding CanPickUpInTerminal}"/>
|
||||
|
||||
@@ -67,6 +67,12 @@ public partial class TaskRowView : UserControl
|
||||
await vm.RunInteractivelyCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
vm.OpenConPtySessionCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:DataType="vm:ConPtyPaneViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- Header: title + close -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding DisplayTitle}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0" />
|
||||
<Button Grid.Column="1" Classes="title-ctrl"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Start-failure banner -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding Terminal.StartError, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="12,6">
|
||||
<TextBlock Classes="meta" Text="{Binding Terminal.StartError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- Embedded ConPTY terminal -->
|
||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||
|
||||
public partial class ConPtyPaneView : UserControl
|
||||
{
|
||||
public ConPtyPaneView() => InitializeComponent();
|
||||
}
|
||||
@@ -2,10 +2,21 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:vmi="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:MissionControlViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||
<UserControl.DataTemplates>
|
||||
<!-- Polymorphic pane templates: both the grid ItemsControl and the focus-mode TabControl
|
||||
resolve per-item content through these (no explicit ItemTemplate on either). -->
|
||||
<DataTemplate DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmm:ConPtyPaneViewModel">
|
||||
<mc:ConPtyPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</UserControl.DataTemplates>
|
||||
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
@@ -21,6 +32,12 @@
|
||||
LetterSpacing="1.4" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||
<PathIcon Data="{StaticResource Icon.Grid}" Width="15" Height="15"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.settings}">
|
||||
@@ -72,22 +89,29 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Grid / empty state -->
|
||||
<!-- Grid / tabs / empty state -->
|
||||
<Panel Margin="6">
|
||||
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
||||
<!-- Overview mode: the existing UniformGrid, one tile per pane -->
|
||||
<ItemsControl ItemsSource="{Binding Panes}" IsVisible="{Binding !IsFocusMode}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid x:CompileBindings="False" Columns="{Binding ColumnCount}" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasMonitors}"
|
||||
<!-- Focus mode: one pane large, switched via tabs -->
|
||||
<TabControl ItemsSource="{Binding Panes}"
|
||||
SelectedItem="{Binding FocusedPane}"
|
||||
IsVisible="{Binding IsFocusMode}">
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmm:IMissionControlPane">
|
||||
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
</TabControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasPanes}"
|
||||
Text="{loc:Tr missionControl.empty}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -262,4 +263,94 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var entity = await verify.Tasks.FirstAsync(t => t.Id == "idleTask");
|
||||
Assert.Equal(TaskStatus.Queued, entity.Status);
|
||||
}
|
||||
|
||||
private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
|
||||
{
|
||||
public override Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("no worktree");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
|
||||
Assert.Single(vm.Panes);
|
||||
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
||||
Assert.True(vm.HasPanes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_SameTaskTwice_DoesNotDuplicate()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
{
|
||||
var worker = new ThrowingLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Panes_CombinesMonitorsThenConPtySessions_InOrder()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
await vm.OpenConPtySessionAsync("t2");
|
||||
|
||||
Assert.Equal(2, vm.Panes.Count);
|
||||
Assert.IsType<TaskMonitorViewModel>(vm.Panes[0]);
|
||||
Assert.IsType<ConPtyPaneViewModel>(vm.Panes[1]);
|
||||
Assert.Equal(2, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
Assert.False(vm.IsFocusMode);
|
||||
vm.ToggleLayoutCommand.Execute(null);
|
||||
Assert.True(vm.IsFocusMode);
|
||||
vm.ToggleLayoutCommand.Execute(null);
|
||||
Assert.False(vm.IsFocusMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class TasksIslandOpenConPtySessionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public TasksIslandOpenConPtySessionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_conpty_cmd_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConPtySessionCommand_RaisesOpenConPtySessionRequested_WithTaskId()
|
||||
{
|
||||
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
|
||||
string? requestedId = null;
|
||||
vm.OpenConPtySessionRequested += id => requestedId = id;
|
||||
|
||||
vm.OpenConPtySessionCommand.Execute(new TaskRowViewModel { Id = "t1" });
|
||||
|
||||
Assert.Equal("t1", requestedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConPtySessionCommand_NullRow_DoesNotRaiseEvent()
|
||||
{
|
||||
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
|
||||
var raised = false;
|
||||
vm.OpenConPtySessionRequested += _ => raised = true;
|
||||
|
||||
vm.OpenConPtySessionCommand.Execute(null);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user