Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,34 +1030,42 @@ public Task<string> ShowApprovalAsync(ApprovalRequest request, CancellationToken
ApprovalDetail.Text = request.Detail ?? "";
ApprovalDetail.Visibility = string.IsNullOrEmpty(request.Detail) ? Visibility.Collapsed : Visibility.Visible;

// Pull the shared, theme-mutated brushes from app resources so the approval diff
// follows the active theme (these used to be hardcoded LightSkyBlue/red/gray, which
// stayed blue under every theme — jarring under E-Ink). Mirrors the transcript's
// diff coloring: command/added -> sky, removed -> red, context -> dim.
var skyBrush = (SolidColorBrush)Application.Current.Resources["MandoSkyBrush"];
var redBrush = (SolidColorBrush)Application.Current.Resources["MandoRedBrush"];
var dimBrush = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];

var rows = new List<DiffLineVm>();
if (request.CommandText != null)
{
rows.Add(new DiffLineVm
{
Text = $"$ {request.CommandText}",
Brush = new SolidColorBrush(Colors.LightSkyBlue)
Brush = skyBrush
});
}
if (request.DiffLines != null)
{
foreach (var line in request.DiffLines)
{
var (prefix, color) = line.LineType switch
var (prefix, brush) = line.LineType switch
{
DiffLineType.Added => ("+ ", Colors.LightSkyBlue),
DiffLineType.Removed => ("- ", Windows.UI.Color.FromArgb(255, 224, 82, 82)),
_ => (" ", Colors.Gray)
DiffLineType.Added => ("+ ", skyBrush),
DiffLineType.Removed => ("- ", redBrush),
_ => (" ", dimBrush)
};
var num = (line.LineType == DiffLineType.Added ? line.NewLineNumber : line.OldLineNumber);
rows.Add(new DiffLineVm
{
Text = $"{(num.HasValue ? num.Value.ToString().PadLeft(4) : " ")} {prefix}{line.Content}",
Brush = new SolidColorBrush(color)
Brush = brush
});
}
if (request.DiffSummary != null)
rows.Add(new DiffLineVm { Text = "", Brush = new SolidColorBrush(Colors.Gray) });
rows.Add(new DiffLineVm { Text = "", Brush = dimBrush });
}
ApprovalDiffList.ItemsSource = rows;
ApprovalBodyScroll.Visibility = rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
Expand Down
61 changes: 46 additions & 15 deletions src/MandoCode.Desktop/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,28 +210,59 @@
toggled by Visibility, never re-parented. WinUI's TabView hosts just the selected
item's content, which would detach a background agent's WebView2 and close its
CoreWebView2 — and the transcript DOM is the only copy of that conversation. -->
<Border Grid.Row="0" Background="{StaticResource MandoPanelBrush}" Padding="10,6"
<!-- Extra top padding balances the reserved scrollbar lane at the bottom of the tab
content, so the tabs sit visually centered in the strip. -->
<Border Grid.Row="0" Background="{StaticResource MandoPanelBrush}" Padding="10,13,10,6"
BorderBrush="{StaticResource MandoBorderBrush}" BorderThickness="0,0,0,1">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <!-- brand -->
<ColumnDefinition Width="Auto"/> <!-- divider -->
<ColumnDefinition Width="*"/> <!-- tabs (scrolls) -->
<ColumnDefinition Width="Auto"/> <!-- divider -->
<ColumnDefinition Width="Auto"/> <!-- permanent add button -->
</Grid.ColumnDefinitions>

<!-- Bottom margin mirrors the tab content's scrollbar lane so the title centers in
the same region as the (top-aligned) tabs, instead of sitting lower. -->
<TextBlock Text="MandoCode" FontSize="17" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{StaticResource MandoAccentBrush}"/>

<ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled" VerticalScrollMode="Disabled">
<StackPanel Orientation="Horizontal" Spacing="4">
<StackPanel x:Name="TabStrip" Orientation="Horizontal" Spacing="4"/>
<Button x:Name="AddTabButton" Click="AddTab_Click" Padding="9,6"
Background="Transparent" BorderThickness="0" VerticalAlignment="Center"
ToolTipService.ToolTip="New agent — its own conversation, folder, model, and settings">
<FontIcon Glyph="&#xE710;" FontSize="12"/>
</Button>
</StackPanel>
Margin="0,0,0,18" Foreground="{StaticResource MandoAccentBrush}"/>

<!-- Vertical divider between the brand and the agent tabs. Insets align it with the
tab band (bottom inset matches the tabs' scrollbar lane). -->
<Rectangle Grid.Column="1" Width="1" VerticalAlignment="Stretch" Margin="0,4,0,22"
Fill="{StaticResource MandoBorderBrush}"/>

<!-- Visible horizontal scrollbar, but the scroll content carries a bottom margin so
WinUI's auto-hide overlay bar sits in that reserved lane BELOW the tabs instead
of drawing on top of them. Mouse wheel / touchpad scroll horizontally too. -->
<ScrollViewer x:Name="TabScroller" Grid.Column="2" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled" VerticalScrollMode="Disabled"
SizeChanged="TabScroller_SizeChanged"
PointerWheelChanged="TabScroller_PointerWheelChanged">
<!-- Bottom lane (Margin) for the scrollbar. Sized for the HIGHLIGHTED (hover/drag)
bar, which grows upward — a thinner lane clears the idle bar but the expanded
one would reach into the tabs. -->
<StackPanel x:Name="TabStrip" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Top" Margin="0,0,0,18"
SizeChanged="TabStrip_SizeChanged"/>
</ScrollViewer>

<!-- Vertical divider between the tabs and the add button (mirrors the brand-side rule). -->
<Rectangle Grid.Column="3" Width="1" VerticalAlignment="Stretch" Margin="0,4,0,22"
Fill="{StaticResource MandoBorderBrush}"/>

<!-- PERMANENT add-agent button, pinned far right (opposite the brand) and OUTSIDE the
scroller, so it never scrolls out of reach when many agents are open. -->
<Button x:Name="AddTabButton" Grid.Column="4" Click="AddTab_Click" Padding="10,6"
Margin="0,0,0,18" Background="Transparent" BorderThickness="0"
VerticalAlignment="Center" ToolTipService.ToolTip="Add agent"
AutomationProperties.Name="New agent">
<StackPanel Orientation="Horizontal" Spacing="7">
<FontIcon Glyph="&#xE710;" FontSize="12"/>
<TextBlock Text="New agent" FontSize="13"/>
</StackPanel>
</Button>
</Grid>
</Border>

Expand Down
90 changes: 85 additions & 5 deletions src/MandoCode.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -770,8 +770,7 @@ private async void ResetTab_Click(object sender, RoutedEventArgs e)
Text = title,
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 170
TextTrimming = TextTrimming.CharacterEllipsis
};

// Gold dot: an approval is waiting in a tab you aren't looking at.
Expand All @@ -797,7 +796,16 @@ private async void ResetTab_Click(object sender, RoutedEventArgs e)
ToolTipService.SetToolTip(options, "Tab options");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(options, "Tab options");

var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 7 };
// A Grid (not a StackPanel) so the label flexes and ellipsizes when the tab is narrow,
// while the badge and options button stay pinned at the right. LayoutTabStrip sets each
// header's Width; this just governs how that width is divided.
var row = new Grid { ColumnSpacing = 7 };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(label, 0);
Grid.SetColumn(badge, 1);
Grid.SetColumn(options, 2);
row.Children.Add(label);
row.Children.Add(badge);
row.Children.Add(options);
Expand All @@ -821,7 +829,7 @@ private void WireHeader(ChatTabEntry entry)
// on the header. Selecting first would be harmless anyway.
entry.Header.Tapped += (_, _) => SelectTab(entry);

var row = (StackPanel)entry.Header.Child;
var row = (Grid)entry.Header.Child;
var options = (Button)row.Children[^1];

var menu = new MenuFlyout();
Expand All @@ -835,7 +843,7 @@ private void WireHeader(ChatTabEntry entry)
var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } };
export.Click += (_, _) => _ = entry.View.ExportTranscriptAsync();

var close = new MenuFlyoutItem { Text = "Close tab", Icon = new FontIcon { Glyph = "" } };
var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
close.Click += (_, _) => CloseTab(entry);

menu.Items.Add(rename);
Expand Down Expand Up @@ -886,8 +894,43 @@ private void SelectTab(ChatTabEntry entry)
_sessions.Activate(entry.View.Session);
RefreshTabStrip();
SwitchPage("chat");

// Reveal the selected tab. Try now (covers clicking an already-laid-out tab) and again when
// the strip re-lays-out (covers a just-added agent, whose width/extent settle a frame later,
// via TabStrip_SizeChanged). Pending stays set until the tab is actually laid out.
_scrollToSelectedPending = true;
DispatcherQueue.TryEnqueue(TryScrollToSelected);
}

private bool _scrollToSelectedPending;

// Scroll the strip so the selected tab is fully visible — a manual ChangeView so a newly created
// (last) tab scrolls ALL THE WAY to the end. StartBringIntoView only did a minimal scroll and ran
// before the extent settled, so it stopped short. No-op once the tab is visible; stays pending
// (retried on the next strip SizeChanged) while the tab isn't laid out yet (ActualWidth == 0).
private void TryScrollToSelected()
{
if (!_scrollToSelectedPending || _selected is null) return;
var header = _selected.Header;
if (header.ActualWidth <= 0) return; // not laid out yet — retry on the next SizeChanged

double left = header.TransformToVisual(TabStrip)
.TransformPoint(new Windows.Foundation.Point(0, 0)).X;
double right = left + header.ActualWidth;
double viewLeft = TabScroller.HorizontalOffset;
double viewRight = viewLeft + TabScroller.ViewportWidth;
const double pad = 8;

if (right > viewRight) // off the right (e.g. a just-added last tab)
TabScroller.ChangeView(right - TabScroller.ViewportWidth + pad, null, null);
else if (left < viewLeft) // off the left
TabScroller.ChangeView(Math.Max(0, left - pad), null, null);

_scrollToSelectedPending = false;
}

private void TabStrip_SizeChanged(object sender, SizeChangedEventArgs e) => TryScrollToSelected();

private void CloseTab(ChatTabEntry entry)
{
var index = _tabs.IndexOf(entry);
Expand Down Expand Up @@ -959,6 +1002,43 @@ private void RefreshTabStrip()
}

RefreshNavIcons();
LayoutTabStrip();
}

// Tabs stay a comfortable width when there's room, and only shrink once enough agents are open
// that they'd otherwise overflow — down to a floor, past which the strip scrolls instead.
private const double TabComfortableWidth = 200;
private const double TabMinWidth = 104;

private void LayoutTabStrip()
{
int count = _tabs.Count;
if (count == 0) return;

// The visible strip is the scroller's viewport; a later SizeChanged fixes up the first
// pass if it hasn't been measured yet (ActualWidth == 0 during early layout).
double viewport = TabScroller.ActualWidth; // tabs only — the add button now lives outside
if (viewport <= 0) return;

double spacing = 4 * Math.Max(0, count - 1); // 4px between adjacent tabs
double avail = viewport - spacing - 8; // margin so rounding never forces a scrollbar

double per = Math.Max(TabMinWidth, Math.Min(TabComfortableWidth, avail / count));
foreach (var tab in _tabs)
tab.Header.Width = per;
}

private void TabScroller_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTabStrip();

// Mouse wheel scrolls the strip horizontally when there are more tabs than fit — a convenience
// on top of the visible scrollbar (which sits in a reserved bottom lane so it never overlaps
// the tabs). Touchpad / touch horizontal scrolling works natively.
private void TabScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
{
if (TabScroller.ScrollableWidth <= 0) return; // everything fits; nothing to scroll
var delta = e.GetCurrentPoint(TabScroller).Properties.MouseWheelDelta;
TabScroller.ChangeView(TabScroller.HorizontalOffset - delta, null, null);
e.Handled = true;
}

private void ApprovalToast_Tapped(object sender, TappedRoutedEventArgs e)
Expand Down
Loading
Loading