feat(mission-control): make overview panes and queue strip individually resizable
Replace the UniformGrid (all tiles forced to equal size) with a real Grid + GridSplitters built in code-behind per pane count/column count, so panes resize individually. The queue strip's fixed 210px column becomes a drag-resizable Grid column (min 160px) that collapses to 0 when nothing is queued, replacing the old dock-based fixed width.
This commit is contained in:
@@ -51,85 +51,98 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Read-only queue strip — collapses when nothing is queued -->
|
||||
<Border DockPanel.Dock="Right"
|
||||
IsVisible="{Binding HasQueued}"
|
||||
Width="210"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1,0,0,0">
|
||||
<DockPanel LastChildFill="True" Margin="10,10">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
|
||||
Text="{loc:Tr missionControl.queue}"
|
||||
<!-- Body: main content | queue splitter | queue strip (widths managed in code-behind
|
||||
so the queue column collapses to zero when nothing is queued). -->
|
||||
<Grid x:Name="BodyGrid">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="0"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Grid / tabs / empty state -->
|
||||
<Panel Grid.Column="0" Margin="6">
|
||||
<!-- Overview mode: a real Grid built in code-behind so each pane is individually
|
||||
resizable via GridSplitters, instead of a UniformGrid where every cell is forced
|
||||
to the same size. -->
|
||||
<Grid x:Name="OverviewGrid" IsVisible="{Binding !IsFocusMode}" />
|
||||
|
||||
<!-- 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}"
|
||||
LetterSpacing="1.4" Margin="0,0,0,8" />
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Queued}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedTaskViewModel">
|
||||
<Button Command="{Binding OpenInAppCommand}"
|
||||
Padding="0" Margin="0,0,0,4"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Panel>
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" />
|
||||
<Border Background="{DynamicResource RunningTintBrush}"
|
||||
BorderBrush="{DynamicResource RunningTintBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding IsRunning}" />
|
||||
<StackPanel Margin="8,6" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.blocked}"
|
||||
IsVisible="{Binding IsBlocked}"
|
||||
Foreground="{DynamicResource AmberBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.running}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Foreground="{DynamicResource StatusRunningBrush}" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Panel>
|
||||
|
||||
<!-- Grid / tabs / empty state -->
|
||||
<Panel Margin="6">
|
||||
<!-- 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>
|
||||
<GridSplitter x:Name="QueueSplitter" Grid.Column="1"
|
||||
IsVisible="{Binding HasQueued}"
|
||||
Width="6"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
ResizeDirection="Columns"
|
||||
ResizeBehavior="PreviousAndNext"/>
|
||||
|
||||
<!-- 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" />
|
||||
</Panel>
|
||||
<!-- Read-only queue strip — collapses when nothing is queued -->
|
||||
<Border Grid.Column="2"
|
||||
IsVisible="{Binding HasQueued}"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1,0,0,0">
|
||||
<DockPanel LastChildFill="True" Margin="10,10">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
|
||||
Text="{loc:Tr missionControl.queue}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
LetterSpacing="1.4" Margin="0,0,0,8" />
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Queued}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedTaskViewModel">
|
||||
<Button Command="{Binding OpenInAppCommand}"
|
||||
Padding="0" Margin="0,0,0,4"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Panel>
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" />
|
||||
<Border Background="{DynamicResource RunningTintBrush}"
|
||||
BorderBrush="{DynamicResource RunningTintBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding IsRunning}" />
|
||||
<StackPanel Margin="8,6" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.blocked}"
|
||||
IsVisible="{Binding IsBlocked}"
|
||||
Foreground="{DynamicResource AmberBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.running}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Foreground="{DynamicResource StatusRunningBrush}" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
@@ -7,9 +12,20 @@ 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;
|
||||
}
|
||||
|
||||
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
|
||||
@@ -28,4 +44,127 @@ public partial class MissionControlView : UserControl
|
||||
|
||||
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 = DataContext as MissionControlViewModel;
|
||||
|
||||
if (_vm is not null)
|
||||
{
|
||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
RebuildOverviewGrid();
|
||||
UpdateQueueColumn();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user