Merge claudedo/527b494ae54f4352aec8e381c5afa4f8

This commit is contained in:
mika kuns
2026-08-05 16:00:38 +02:00
2 changed files with 228 additions and 76 deletions
@@ -51,85 +51,98 @@
</Grid> </Grid>
</Border> </Border>
<!-- Read-only queue strip — collapses when nothing is queued --> <!-- Body: main content | queue splitter | queue strip (widths managed in code-behind
<Border DockPanel.Dock="Right" so the queue column collapses to zero when nothing is queued). -->
IsVisible="{Binding HasQueued}" <Grid x:Name="BodyGrid">
Width="210" <Grid.ColumnDefinitions>
Background="{DynamicResource DeepBrush}" <ColumnDefinition Width="*"/>
BorderBrush="{DynamicResource LineBrush}" <ColumnDefinition Width="Auto"/>
BorderThickness="1,0,0,0"> <ColumnDefinition Width="0"/>
<DockPanel LastChildFill="True" Margin="10,10"> </Grid.ColumnDefinitions>
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
Text="{loc:Tr missionControl.queue}" <!-- 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}" Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" /> HorizontalAlignment="Center" VerticalAlignment="Center" />
<ScrollViewer> </Panel>
<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 / tabs / empty state --> <GridSplitter x:Name="QueueSplitter" Grid.Column="1"
<Panel Margin="6"> IsVisible="{Binding HasQueued}"
<!-- Overview mode: the existing UniformGrid, one tile per pane --> Width="6"
<ItemsControl ItemsSource="{Binding Panes}" IsVisible="{Binding !IsFocusMode}"> Background="Transparent"
<ItemsControl.ItemsPanel> HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
<ItemsPanelTemplate> ResizeDirection="Columns"
<UniformGrid x:CompileBindings="False" Columns="{Binding ColumnCount}" /> ResizeBehavior="PreviousAndNext"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<!-- Focus mode: one pane large, switched via tabs --> <!-- Read-only queue strip — collapses when nothing is queued -->
<TabControl ItemsSource="{Binding Panes}" <Border Grid.Column="2"
SelectedItem="{Binding FocusedPane}" IsVisible="{Binding HasQueued}"
IsVisible="{Binding IsFocusMode}"> Background="{DynamicResource DeepBrush}"
<TabControl.ItemTemplate> BorderBrush="{DynamicResource LineBrush}"
<DataTemplate x:DataType="vmm:IMissionControlPane"> BorderThickness="1,0,0,0">
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" /> <DockPanel LastChildFill="True" Margin="10,10">
</DataTemplate> <TextBlock DockPanel.Dock="Top" Classes="eyebrow"
</TabControl.ItemTemplate> Text="{loc:Tr missionControl.queue}"
</TabControl> Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" />
<TextBlock IsVisible="{Binding !HasPanes}" <ScrollViewer>
Text="{loc:Tr missionControl.empty}" <ItemsControl ItemsSource="{Binding Queued}">
Foreground="{DynamicResource TextMuteBrush}" <ItemsControl.ItemTemplate>
HorizontalAlignment="Center" VerticalAlignment="Center" /> <DataTemplate x:DataType="vm:QueuedTaskViewModel">
</Panel> <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> </DockPanel>
</UserControl> </UserControl>
@@ -1,5 +1,10 @@
using System;
using System.Collections.Specialized;
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
@@ -7,9 +12,20 @@ namespace ClaudeDo.Ui.Views.MissionControl;
public partial class MissionControlView : UserControl 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() public MissionControlView()
{ {
InitializeComponent(); 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. // 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); 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);
}
}
} }