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:
mika kuns
2026-07-23 16:47:15 +02:00
parent d28c63d2df
commit 0513265c49
15 changed files with 474 additions and 11 deletions
@@ -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);
}
}