Files
ClaudeDo/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
T
Mika Kuns d3db4455fa feat(ui): Mission Control Recent Sessions mit verschachtelten Handoffs
Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom
InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header
listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit
Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
2026-09-28 12:11:21 +02:00

243 lines
9.4 KiB
C#

using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views.MissionControl;
public partial class MissionControlView : UserControl
{
private const double SplitterSize = 6;
private const double DefaultQueueWidth = 210;
private const double MinQueueWidth = 160;
private const double MinPaneWidth = 220;
private const double MinPaneHeight = 140;
private MissionControlViewModel? _vm;
private GridLength _queueWidth = new(DefaultQueueWidth, GridUnitType.Pixel);
public MissionControlView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
// Resume/Brief buttons don't close a plain Flyout on their own.
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
}
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void BrowseForSessionAsync()
{
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 OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm is not null)
{
_vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
_vm.ConfirmAsync = null;
}
_vm = DataContext as MissionControlViewModel;
if (_vm is not null)
{
_vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
_vm.ConfirmAsync = ShowConfirmAsync;
}
RebuildOverviewGrid();
UpdateQueueColumn();
}
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
// through WindowDialogService: it's owned by the main window, so showing it while Mission
// Control (a separate Window) is focused would put it behind Mission Control.
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = Loc.T("dialog.confirmTitle"),
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MissionControlViewModel.ColumnCount))
RebuildOverviewGrid();
else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued))
UpdateQueueColumn();
else if (e.PropertyName == nameof(MissionControlViewModel.IsFocusMode))
// The now-visible layout's hosts must re-steal their pane views from the hidden
// one (see ConPtyPaneHost) — posted so it runs after the visibility change and
// the TabControl's content presenter have gone through a layout pass.
Avalonia.Threading.Dispatcher.UIThread.Post(ReclaimVisiblePaneHosts,
Avalonia.Threading.DispatcherPriority.Loaded);
}
private void ReclaimVisiblePaneHosts()
{
foreach (var host in this.GetVisualDescendants().OfType<ConPtyPaneHost>())
host.Reclaim();
}
// The queue column is Pixel-widthed (so the splitter can drag it) — when nothing is
// queued it collapses to 0 instead of leaving a blank gap, and remembers the last
// dragged width for the rest of this window's lifetime (not persisted beyond it).
private void UpdateQueueColumn()
{
var queueColumn = BodyGrid.ColumnDefinitions[2];
if (_vm?.HasQueued == true)
{
queueColumn.MinWidth = MinQueueWidth;
queueColumn.Width = _queueWidth;
}
else
{
queueColumn.MinWidth = 0;
queueColumn.Width = new GridLength(0, GridUnitType.Pixel);
}
}
// Replaces the old UniformGrid with a real Grid + GridSplitters so panes resize
// individually. Column splitters span every row (resize that whole column vs. its
// neighbor); row splitters span every column. Rebuilt from scratch whenever the pane
// count or column count changes — per-pane sizes are intentionally not preserved across
// a rebuild (session-only resizing, reset to the default split when panes come/go).
private void RebuildOverviewGrid()
{
OverviewGrid.Children.Clear();
OverviewGrid.ColumnDefinitions.Clear();
OverviewGrid.RowDefinitions.Clear();
var panes = _vm?.Panes;
if (panes is not { Count: > 0 }) return;
var columns = Math.Max(1, _vm!.ColumnCount);
var rows = (int)Math.Ceiling(panes.Count / (double)columns);
for (var c = 0; c < columns; c++)
{
if (c > 0) OverviewGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
OverviewGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)) { MinWidth = MinPaneWidth });
}
for (var r = 0; r < rows; r++)
{
if (r > 0) OverviewGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
OverviewGrid.RowDefinitions.Add(new RowDefinition(new GridLength(1, GridUnitType.Star)) { MinHeight = MinPaneHeight });
}
var totalGridRows = rows * 2 - 1;
var totalGridCols = columns * 2 - 1;
for (var c = 1; c < columns; c++)
{
var splitter = new GridSplitter
{
Width = SplitterSize,
Background = Brushes.Transparent,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
ResizeDirection = GridResizeDirection.Columns,
ResizeBehavior = GridResizeBehavior.PreviousAndNext,
};
Grid.SetColumn(splitter, c * 2 - 1);
Grid.SetRow(splitter, 0);
Grid.SetRowSpan(splitter, totalGridRows);
OverviewGrid.Children.Add(splitter);
}
for (var r = 1; r < rows; r++)
{
var splitter = new GridSplitter
{
Height = SplitterSize,
Background = Brushes.Transparent,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
ResizeDirection = GridResizeDirection.Rows,
ResizeBehavior = GridResizeBehavior.PreviousAndNext,
};
Grid.SetColumn(splitter, 0);
Grid.SetColumnSpan(splitter, totalGridCols);
Grid.SetRow(splitter, r * 2 - 1);
OverviewGrid.Children.Add(splitter);
}
for (var i = 0; i < panes.Count; i++)
{
var presenter = new ContentControl { Content = panes[i] };
Grid.SetColumn(presenter, (i % columns) * 2);
Grid.SetRow(presenter, (i / columns) * 2);
OverviewGrid.Children.Add(presenter);
}
}
}