feat(interactive): New session button for ad-hoc ConPTY sessions

Adds a 'New session' header button in Mission Control that opens a folder picker
and starts a task-less embedded ConPTY session in the chosen directory. ConPtyPaneViewModel
TaskId is now nullable (ad-hoc panes have no task and are never deduped) with a
CreateAdHoc factory; the view does the picking, the VM stays picker-agnostic.
This commit is contained in:
mika kuns
2026-07-23 16:47:15 +02:00
parent 9ab48d7094
commit 3feb08d9d9
7 changed files with 127 additions and 4 deletions
@@ -263,6 +263,7 @@
"detach": "Abdocken",
"redock": "Andocken",
"windowTitle": "Mission Control",
"newSession": "Neue Sitzung",
"clearFinished": "Erledigte entfernen",
"empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen",
@@ -263,6 +263,7 @@
"detach": "Detach",
"redock": "Re-dock",
"windowTitle": "Mission Control",
"newSession": "New session",
"clearFinished": "Clear finished",
"empty": "No running tasks",
"settings": "Settings",
@@ -7,14 +7,15 @@ 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
/// Command Center pane hosting an embedded ConPTY terminal for either one task's interactive
/// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/>
/// is null — ad-hoc panes are never deduped, unlike task-based ones). 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; }
public string? TaskId { get; }
[ObservableProperty] private string _displayTitle;
@@ -26,7 +27,9 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
/// <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)
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.</summary>
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
{
TaskId = taskId;
_displayTitle = displayTitle;
@@ -34,6 +37,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
Terminal.Start(descriptor);
}
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
=> new(null, displayTitle, descriptor);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -265,6 +266,30 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
{
if (string.IsNullOrEmpty(directory)) return;
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(title)) title = directory;
try
{
var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = ConPtyPaneViewModel.CreateAdHoc(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)
@@ -32,6 +32,12 @@
LetterSpacing="1.4" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<Button Classes="icon-btn"
Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
<Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}"
ToolTip.Tip="{Binding LayoutToggleLabel}">
@@ -2,6 +2,7 @@ using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
@@ -21,6 +22,23 @@ public partial class MissionControlView : UserControl
AddHandler(DragDrop.DropEvent, OnPaneDrop);
}
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose a directory",
AllowMultiple = false,
});
if (folders.Count == 0) return;
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
}
private void OnPaneDragOver(object? sender, DragEventArgs e)
{
var dt = e.DataTransfer;
@@ -341,6 +341,71 @@ public class MissionControlViewModelTests : IDisposable
Assert.Empty(vm.Panes);
}
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> throw new InvalidOperationException("bad directory");
}
[Fact]
public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject"));
Assert.Single(vm.ConPtySessions);
Assert.Null(vm.ConPtySessions[0].TaskId);
Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle);
Assert.Single(vm.Panes);
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
}
[Fact]
public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenAdHocConPtySessionAsync(dir);
Assert.Equal(2, vm.ConPtySessions.Count);
Assert.Equal(2, vm.Panes.Count);
Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId));
}
[Fact]
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
{
var worker = new ThrowingAdHocLaunchSpecWorker();
using var vm = BuildVm(worker);
string? error = null;
vm.ErrorReported += msg => error = msg;
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes);
Assert.NotNull(error);
}
[Fact]
public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
var pane = vm.ConPtySessions[0];
pane.CloseCommand.Execute(null);
Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes);
}
[Fact]
public void ToggleLayoutCommand_FlipsIsFocusMode()
{