diff --git a/CHANGELOG.md b/CHANGELOG.md index 484dc8f..278995c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Split view tells you which agent is which, and you rearrange it by dragging.** Each pane now + carries its own header naming the agent shown beneath it, so you read the name where the agent is + instead of matching it up from a strip along the top. Agents already on screen drop out of the tab + strip, leaving it as a list of what is *not* currently visible. Drag a tab onto a pane to put that + agent there, or drag one pane's header onto another to swap the two — each target says what the + drop will do before you release. With a single agent open, dragging another onto the chat area + starts a split, and which half you hover decides which side the dragged agent takes. The per-pane + dropdowns are gone; the tab menu still offers "Add to split view" for anyone who would rather not + drag. - **Agents can talk to each other.** Typing `@` now offers the other open agents before project files, so you can address one by name from another's conversation. An agent can check what another is doing, read its conversation, ask it a question and get a real answer back, or hand it a whole diff --git a/src/MandoCode.Desktop/Controls/DragHandleGrid.cs b/src/MandoCode.Desktop/Controls/DragHandleGrid.cs new file mode 100644 index 0000000..1fdf431 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/DragHandleGrid.cs @@ -0,0 +1,31 @@ +using Microsoft.UI.Input; +using Microsoft.UI.Xaml.Controls; + +namespace MandoCode.Desktop.Controls; + +/// +/// A that shows the move cursor on hover, so something you can pick up and drop +/// elsewhere reads that way before you try it. Used as the content of agent tabs and pane headers, +/// both of which can be dragged into a split-view pane. +/// +/// Windows has no grab/grabbing cursor the way CSS does. SizeAll — the four-way arrow — +/// is its convention for "this object can be moved", and it is the closer match here: +/// Hand means "this is a link", which would say the wrong thing about a header whose click +/// selects rather than navigates. +/// +/// A Grid rather than a Border, for the same reason is: Border is +/// sealed, and ProtectedCursor is reachable only from a derived type. Since both headers +/// already hold their content in a Grid, the cursor rides on the content and covers everything but +/// the surrounding padding. +/// +/// Carries the same hazard as ResizeGrip: assigning the cursor before the element is in the +/// visual tree fast-fails WinUI natively (STATUS_STOWED_EXCEPTION 0xC000027B). Loaded is the only +/// safe moment. +/// +public sealed class DragHandleGrid : Grid +{ + public DragHandleGrid() + { + Loaded += (_, _) => ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.SizeAll); + } +} diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs index 39a87cb..3291d41 100644 --- a/src/MandoCode.Desktop/MainWindow.History.cs +++ b/src/MandoCode.Desktop/MainWindow.History.cs @@ -395,14 +395,25 @@ private async void HistoryDeleteGroup_Click(object sender, RoutedEventArgs e) // 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 }; + // Wider than the old 7 so the neutral gutter beside the badge and the options button is a + // little more forgiving — the cursor should have turned back to an arrow before the pointer + // reaches something clickable. + var row = new Grid { ColumnSpacing = 10 }; 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); + + // Only the NAME carries the move cursor. The badge and the "..." button are things you + // click, and a move cursor over them would promise a drag that does not start there — the + // button swallows the press. ProtectedCursor applies to an element and its children, so the + // handle has to wrap just the label rather than the whole row. + var nameHandle = new Controls.DragHandleGrid(); + nameHandle.Children.Add(label); + + Grid.SetColumn(nameHandle, 0); Grid.SetColumn(badge, 1); Grid.SetColumn(options, 2); - row.Children.Add(label); + row.Children.Add(nameHandle); row.Children.Add(badge); row.Children.Add(options); @@ -425,6 +436,12 @@ private void WireHeader(ChatTabEntry entry) // on the header. Selecting first would be harmless anyway. entry.Header.Tapped += (_, _) => SelectTab(entry); + // A tab is a drag source for the split view: pick it up and drop it on a pane to put that + // agent there. Harmless outside a split — nothing accepts the drop, so it simply ends. + entry.Header.CanDrag = true; + entry.Header.DragStarting += (_, args) => BeginAgentDrag(args, entry); + entry.Header.DropCompleted += (_, _) => EndAgentDrag(); + var row = (Grid)entry.Header.Child; var options = (Button)row.Children[^1]; diff --git a/src/MandoCode.Desktop/MainWindow.Split.cs b/src/MandoCode.Desktop/MainWindow.Split.cs index 6f15dde..9938074 100644 --- a/src/MandoCode.Desktop/MainWindow.Split.cs +++ b/src/MandoCode.Desktop/MainWindow.Split.cs @@ -37,6 +37,26 @@ public sealed partial class MainWindow private readonly List _splitPanes = new(); + /// + /// One header per pane, naming the agent shown beneath it. Rebuilt with the tracks, the same way + /// the dividers are. + /// + /// They exist because a strip along the top is a poor map for a spatial layout: with four + /// panes you have to hold "second chip means bottom-left" in your head, which is exactly the work + /// the split is meant to save. A label sitting on the thing it names needs no mapping. + /// + /// Deliberately a SIBLING of the agent view in the same grid cell rather than a wrapper + /// around it. Wrapping would reparent the view, and reparenting tears down its WebView2 — the + /// constraint this whole file is built around. Two children share the cell; the header pins to + /// the top and the view carries a matching top margin. + /// + private readonly List _paneHeaders = new(); + + /// Height of a pane header, and therefore the top margin a paned view carries. Kept + /// tight on purpose — in a 2x2 it is spent twice over, and it is chat area either way — but tall + /// enough that a 13px name is not squeezed against the edges. + private const double PaneHeaderHeight = 30; + // Divider positions as star fractions — one entry per pane COLUMN and per pane ROW. Reset to // equal when the layout shape changes; otherwise preserved across page visits and restarts. private List _colFractions = new(); @@ -201,11 +221,39 @@ private void ApplyPaneLayout() var (row, col) = pane >= 0 ? PaneLayout.Cell(pane, count) : (0, 0); Grid.SetRow(tab.View, row * 2); Grid.SetColumn(tab.View, col * 2); + + // Room for the header sharing this cell. Cleared in single view, where there is none. + tab.View.Margin = pane >= 0 ? new Thickness(0, PaneHeaderHeight, 0, 0) : new Thickness(0); } + BuildPaneHeaders(split, count); + + // The bar keeps the add/exit controls and the layout hint, but the per-pane pickers are gone + // — swapping is done by dragging a tab onto the pane it should occupy. SplitBar.Visibility = split ? Visibility.Visible : Visibility.Collapsed; } + /// + /// Rebuilds the per-pane headers. Torn down and recreated wholesale on every layout pass, like + /// the dividers: they are cheap, and a rebuild cannot drift out of step with the pane set. + /// + private void BuildPaneHeaders(bool split, int count) + { + foreach (var header in _paneHeaders) TabHost.Children.Remove(header); + _paneHeaders.Clear(); + if (!split) return; + + for (int i = 0; i < _splitPanes.Count; i++) + { + var (row, col) = PaneLayout.Cell(i, count); + var header = BuildPaneHeader(i, _splitPanes[i]); + Grid.SetRow(header, row * 2); + Grid.SetColumn(header, col * 2); + TabHost.Children.Add(header); + _paneHeaders.Add(header); + } + } + /// Rebuilds TabHost's tracks for a pane grid of the given shape and recreates the /// dividers. Track layout is pane, divider, pane, … — 2c-1 columns and 2r-1 rows. Only track /// definitions and divider elements change here; agent views are never removed from the tree, @@ -250,6 +298,404 @@ private void BuildPaneTracks(int rows, int cols) for (int r = 0; r < rows - 1; r++) AddPaneGrip(vertical: false, r, 2 * r + 1, trackCols); } + /// + /// One pane header: the agent's name and folder, a close button, and — the point of the whole + /// rework — a drag source and a drop target. + /// + /// Drag it onto another pane to swap the two. Drag a tab from the strip onto it to put + /// that agent here instead. Both are the same operation on , which is + /// an ordered list, so the model needed no changes at all. + /// + private Border BuildPaneHeader(int index, ChatTabEntry pane) + { + var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"]; + var isActive = ReferenceEquals(pane, _selected); + + var label = new TextBlock + { + Text = pane.View.Session.Title, + // 13 to match the tab strip: this header is doing a tab's job, so it should carry the + // same weight as one rather than reading as a caption above the pane. + FontSize = 13, + FontWeight = isActive ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal, + Foreground = isActive ? accent : (SolidColorBrush)Application.Current.Resources["MandoTextBrush"], + VerticalAlignment = VerticalAlignment.Center, + }; + + var close = new Button + { + Content = new FontIcon { Glyph = "\uE711", FontSize = 10 }, + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }; + var paneRef = pane; + close.Click += (_, _) => DispatcherQueue.TryEnqueue(() => RemovePane(paneRef)); + ToolTipService.SetToolTip(close, "Remove from split view"); + + // The move cursor covers the name only, not the close button beside it — same reason as the + // tab strip: a move cursor over something you click promises a drag that will not start. + var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center }; + content.Children.Add(new FontIcon { Glyph = "\uE8BD", FontSize = 12, Opacity = 0.6, VerticalAlignment = VerticalAlignment.Center }); + // Name only. The agent's own header already shows the folder it works in, directly below + // this — repeating it here spends pane width saying the same thing twice. + content.Children.Add(label); + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var nameHandle = new Controls.DragHandleGrid(); + nameHandle.Children.Add(content); + grid.Children.Add(nameHandle); + Grid.SetColumn(close, 1); + grid.Children.Add(close); + + var header = new Border + { + Child = grid, + Height = PaneHeaderHeight, + VerticalAlignment = VerticalAlignment.Top, + Padding = new Thickness(9, 0, 4, 0), + Background = (SolidColorBrush)Application.Current.Resources["MandoPanelBrush"], + BorderBrush = isActive ? accent : (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"], + // Accent along the BOTTOM edge on the active pane: it reads as the header belonging to + // the view under it rather than as a box floating above it. + BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 1), + Tag = index, + CanDrag = true, + AllowDrop = true, + }; + + header.Tapped += (_, _) => SelectTab(paneRef); + header.DragStarting += (_, args) => BeginAgentDrag(args, paneRef); + header.DropCompleted += (_, _) => EndAgentDrag(); + header.DragOver += PaneTarget_DragOver; + header.Drop += (sender, args) => DropAgentOnPane(sender, args); + ToolTipService.SetToolTip(header, $"{pane.View.Session.Title} — drag onto another pane to swap"); + return header; + } + + // ============================================================ + // Dragging an agent into a pane + // ============================================================ + + /// + /// The clipboard format an agent drag carries — a session's PersistKey. + /// + /// A private format rather than Text on purpose: the chat surface already accepts + /// dropped files and text as attachments, and a bare string would make an agent drag look like + /// something to attach. With a format of its own, neither drag can be mistaken for the other and + /// the two features need know nothing about each other. + /// + private const string AgentDragFormat = "MandoCode/AgentKey"; + + /// The agent currently being dragged, or null. Held because a DataView's custom formats + /// can only be read asynchronously, and DragOver has to decide synchronously whether to accept. + private ChatTabEntry? _draggingAgent; + + /// Starts an agent drag from either a strip tab or a pane header. + private void BeginAgentDrag(DragStartingEventArgs args, ChatTabEntry entry) + { + _draggingAgent = entry; + args.Data.SetText(entry.View.Session.PersistKey); + args.Data.SetData(AgentDragFormat, entry.View.Session.PersistKey); + args.Data.RequestedOperation = DataPackageOperation.Move; + ShowPaneDropTargets(entry); + } + + private void EndAgentDrag() + { + _draggingAgent = null; + HidePaneDropTargets(); + } + + /// + /// Tracks which half of the surface the pointer is over and previews that outcome. Crossing the + /// midline moves the highlight and rewrites the caption, so which side the dragged agent takes + /// is chosen by aiming rather than dictated — dropping on the left means "put this one on the + /// left", which is what the gesture looks like it should mean. + /// + private void StartSplitTarget_DragOver(object sender, DragEventArgs e) + { + if (_draggingAgent == null || _selected == null || sender is not FrameworkElement surface) return; + + var onLeft = e.GetPosition(surface).X < surface.ActualWidth / 2; + if (_startSplitPreview != null) + _startSplitPreview.HorizontalAlignment = onLeft ? HorizontalAlignment.Left : HorizontalAlignment.Right; + + if (onLeft != _startSplitDraggedOnLeft || _startSplitCaption?.Text is null or "") + { + _startSplitDraggedOnLeft = onLeft; + if (_startSplitCaption != null) + _startSplitCaption.Text = onLeft + ? $"{_draggingAgent.View.Session.Title} on the left, {_selected.View.Session.Title} on the right" + : $"{_selected.View.Session.Title} on the left, {_draggingAgent.View.Session.Title} on the right"; + } + + e.AcceptedOperation = DataPackageOperation.Move; + e.DragUIOverride.IsGlyphVisible = false; + e.DragUIOverride.Caption = onLeft ? "Place on the left" : "Place on the right"; + e.Handled = true; + } + + private void PaneTarget_DragOver(object sender, DragEventArgs e) + { + if (_draggingAgent == null) return; + e.AcceptedOperation = DataPackageOperation.Move; + e.DragUIOverride.IsGlyphVisible = false; + e.DragUIOverride.Caption = CaptionFor(sender); + e.Handled = true; + } + + private string CaptionFor(object target) + { + var index = (target as FrameworkElement)?.Tag as int?; + if (_draggingAgent == null) return "Move here"; + + // Single view: the drop starts a split rather than filling a pane, so say that instead of + // naming an occupant there isn't one of. + if (index == StartSplitTag) + return _selected == null + ? "Open beside the current agent" + : $"Open beside {_selected.View.Session.Title}"; + + if (index is not { } i || i < 0 || i >= _splitPanes.Count) return "Move here"; + var occupant = _splitPanes[i]; + return ReferenceEquals(occupant, _draggingAgent) + ? $"{_draggingAgent.View.Session.Title} is already here" + : _splitPanes.Contains(_draggingAgent) + ? $"Swap with {occupant.View.Session.Title}" + : $"Replace {occupant.View.Session.Title}"; + } + + /// + /// Puts the dragged agent in the pane it was dropped on. + /// + /// Both gestures are one operation on an ordered list. If the dragged agent is already + /// paned, the two exchange slots — a swap, because dropping onto an occupied pane can only mean + /// "these two should trade places". If it is not, it takes the slot and the previous occupant + /// leaves the split, which is what dragging a tab in from the strip visibly does. + /// + private void DropAgentOnPane(object sender, DragEventArgs e) + { + var dragged = _draggingAgent; + var wasSelected = _selected; + EndAgentDrag(); + if (dragged == null) return; + if ((sender as FrameworkElement)?.Tag is not int target) return; + + if (target == StartSplitTag) + { + if (wasSelected == null || ReferenceEquals(wasSelected, dragged)) return; + _splitPanes.Clear(); + // Pane order follows the half the pointer was over when you let go. + if (_startSplitDraggedOnLeft) + { + _splitPanes.Add(dragged); + _splitPanes.Add(wasSelected); + } + else + { + _splitPanes.Add(wasSelected); + _splitPanes.Add(dragged); + } + ResetPaneFractions(); + e.Handled = true; + + // Selection stays where it was. AddPane moves it to the newcomer, which is right when + // you asked for that agent by name — but here you dropped something NEXT TO what you + // were reading, so being moved off it would undo half the gesture. + DispatcherQueue.TryEnqueue(() => + { + SelectTab(wasSelected); + ApplyPaneLayout(); + RefreshTabStrip(); + RefreshSplitBar(); + RefreshSplitButton(); + SaveWorkspace(); + }); + return; + } + + if (target < 0 || target >= _splitPanes.Count) return; + + var existing = _splitPanes.FindIndex(p => ReferenceEquals(p, dragged)); + if (existing == target) return; // dropped where it already is + + if (existing >= 0) + (_splitPanes[existing], _splitPanes[target]) = (_splitPanes[target], _splitPanes[existing]); + else + _splitPanes[target] = dragged; + + e.Handled = true; + // Deferred for the same reason the chip menus were: this restructures the visual tree, which + // is not legal from inside the event that is still delivering the drop. + DispatcherQueue.TryEnqueue(() => + { + SelectTab(dragged); + ApplyPaneLayout(); + RefreshTabStrip(); + RefreshSplitBar(); + SaveWorkspace(); + }); + } + + /// + /// Reveals a drop target over each pane for the duration of a drag. + /// + /// Full-pane rather than header-only, which needs the trick the file-attachment drop + /// already uses: a WebView2 swallows drags over its own surface, but an XAML element drawn ON TOP + /// of it takes the drag back, because the OS retargets to whatever is topmost. Since an agent + /// drag starts in XAML we can raise these the moment it begins, without waiting to be told by the + /// WebView the way an incoming file drag has to be. + /// + /// They also answer discoverability: the droppable places light up as soon as you pick a + /// tab up, so the gesture does not have to be known in advance to be found. + /// + private void ShowPaneDropTargets(ChatTabEntry dragged) + { + HidePaneDropTargets(); + + // Not split yet: dropping onto the chat area STARTS one, keeping the agent you were reading + // on the left and putting the dragged agent beside it. That is the gesture's plain meaning — + // "show me this one as well" — and requiring a trip to the Split button first to express it + // would be the kind of ceremony dragging exists to remove. + if (!SplitActive) + { + if (_currentPage != "chat" || _selected == null || ReferenceEquals(_selected, dragged)) return; + AddSingleViewDropTarget(dragged); + return; + } + + var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"]; + int count = _splitPanes.Count; + for (int i = 0; i < count; i++) + { + var (row, col) = PaneLayout.Cell(i, count); + var occupant = _splitPanes[i]; + var isSelf = ReferenceEquals(occupant, dragged); + + var caption = new TextBlock + { + Text = isSelf ? "Already here" : CaptionFor(i, occupant, dragged), + Foreground = accent, + FontSize = 13, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + IsHitTestVisible = false, + }; + + var target = new Grid + { + Background = new SolidColorBrush(Windows.UI.Color.FromArgb(isSelf ? (byte)0x30 : (byte)0x88, 0, 0, 0)), + BorderBrush = accent, + BorderThickness = new Thickness(isSelf ? 0 : 2), + Margin = new Thickness(0, PaneHeaderHeight, 0, 0), + AllowDrop = true, + Tag = i, + }; + target.Children.Add(caption); + target.DragOver += PaneTarget_DragOver; + target.Drop += DropAgentOnPane; + + Grid.SetRow(target, row * 2); + Grid.SetColumn(target, col * 2); + TabHost.Children.Add(target); + _paneDropTargets.Add(target); + } + } + + private string CaptionFor(int index, ChatTabEntry occupant, ChatTabEntry dragged) => + _splitPanes.Contains(dragged) + ? $"Swap with {occupant.View.Session.Title}" + : $"Replace {occupant.View.Session.Title}"; + + /// + /// The one drop target shown in single view. Its hit area is the WHOLE chat surface, but only + /// the right half is drawn — the outcome is fixed, so the highlight shows where the dragged + /// agent will land while the generous target keeps a slightly-off aim from doing nothing. + /// + private void AddSingleViewDropTarget(ChatTabEntry dragged) + { + var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"]; + + var preview = new Border + { + BorderBrush = accent, + BorderThickness = new Thickness(2), + Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0x88, 0, 0, 0)), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Stretch, + IsHitTestVisible = false, + Child = new TextBlock + { + // Rewritten on every DragOver to name the resulting left/right order. + Text = $"{_selected!.View.Session.Title} on the left, {dragged.View.Session.Title} on the right", + Foreground = accent, + FontSize = 13, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + TextAlignment = TextAlignment.Center, + Margin = new Thickness(12), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + + _startSplitPreview = preview; + _startSplitCaption = (TextBlock)preview.Child; + _startSplitDraggedOnLeft = false; // default until the pointer says otherwise + + var target = new Grid + { + AllowDrop = true, + Tag = StartSplitTag, + // A Grid with a NULL Background takes no part in hit testing, so it would draw the + // preview below and then never receive the drop — which is exactly what it did. A brush + // is what makes the surface real to the pointer; Transparent would be enough, and the + // faint scrim also dims the half that is about to be given away. + Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0x55, 0, 0, 0)), + }; + target.Children.Add(preview); + target.SizeChanged += (_, e) => preview.Width = e.NewSize.Width / 2; + target.DragOver += StartSplitTarget_DragOver; + target.Drop += DropAgentOnPane; + + // Spans every track so it covers the surface whatever shape the grid was left in. + Grid.SetRow(target, 0); + Grid.SetColumn(target, 0); + Grid.SetRowSpan(target, 3); + Grid.SetColumnSpan(target, 5); + TabHost.Children.Add(target); + _paneDropTargets.Add(target); + } + + /// Tag on the single-view target: this drop CREATES the split rather than filling a + /// pane in one, so it cannot be a pane index. + private const int StartSplitTag = -1; + + /// The half of the surface the pointer is currently over, and therefore where the + /// dragged agent will land. Updated as the pointer moves so the choice is made by aiming rather + /// than by accepting a fixed outcome. + private bool _startSplitDraggedOnLeft; + + /// The highlight showing which half is about to be taken; moved as the pointer crosses + /// the midline. + private Border? _startSplitPreview; + private TextBlock? _startSplitCaption; + + private void HidePaneDropTargets() + { + foreach (var target in _paneDropTargets) TabHost.Children.Remove(target); + _paneDropTargets.Clear(); + _startSplitPreview = null; + _startSplitCaption = null; + } + + private readonly List _paneDropTargets = new(); + /// Creates one divider. goes in Tag — it's the fraction-list /// slot the drag repartitions. private void AddPaneGrip(bool vertical, int index, int track, int span) @@ -314,10 +760,9 @@ private void RefreshSplitBar() _syncingSplitBar = true; try { - PaneChips.Children.Clear(); - for (int i = 0; i < _splitPanes.Count; i++) - PaneChips.Children.Add(BuildPaneChip(i, _splitPanes[i])); - + // No per-pane chips any more. Naming a pane by its ORDINAL — "pane 2 is Jetik" — made + // you hold the mapping in your head, which is the work the split is supposed to save. + // The pane headers say it in place instead, and dragging replaces the pickers. // Add-pane picker: only agents that aren't already shown — an agent can't occupy two // panes, so listing one would be a no-op. Deferred a tick like every other split // mutation (it restructures the visual tree and rebuilds this bar). @@ -340,88 +785,6 @@ private void RefreshSplitBar() finally { _syncingSplitBar = false; } } - /// One pane chip: position, agent name, a picker, and a remove button. Both menu - /// actions are deferred to the next dispatcher tick — the reaction restructures the visual tree - /// (moves a ChatTabView between grid cells) and rebuilds this bar, neither of which is legal - /// from inside a flyout's click handler. - private Border BuildPaneChip(int index, ChatTabEntry pane) - { - var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"]; - var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"]; - var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"]; - var isActive = ReferenceEquals(pane, _selected); - - var ordinal = new TextBlock - { - Text = (index + 1).ToString(), - FontSize = 10, - Opacity = 0.5, - VerticalAlignment = VerticalAlignment.Center, - }; - - var label = new TextBlock - { - Text = pane.View.Session.Title, - FontSize = 12, - MaxWidth = 150, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - Foreground = isActive ? accent : dim, - }; - - var picker = new Button - { - Padding = new Thickness(2), - Background = new SolidColorBrush(Colors.Transparent), - BorderThickness = new Thickness(0), - VerticalAlignment = VerticalAlignment.Center, - Content = new FontIcon { Glyph = "", FontSize = 9 }, // ChevronDown - }; - ToolTipService.SetToolTip(picker, "Show a different agent in this pane"); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(picker, $"Pane {index + 1} agent"); - - var menu = new MenuFlyout(); - foreach (var tab in _tabs) - { - var target = tab; - var item = new MenuFlyoutItem { Text = target.View.Session.Title }; - if (ReferenceEquals(target, pane)) - item.Icon = new FontIcon { Glyph = "" }; // check — the current occupant - item.Click += (_, _) => DispatcherQueue.TryEnqueue(() => SetPane(index, target)); - menu.Items.Add(item); - } - picker.Flyout = menu; - - var remove = new Button - { - Padding = new Thickness(2), - Background = new SolidColorBrush(Colors.Transparent), - BorderThickness = new Thickness(0), - VerticalAlignment = VerticalAlignment.Center, - Content = new FontIcon { Glyph = "", FontSize = 9 }, // close - }; - ToolTipService.SetToolTip(remove, "Remove this pane"); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(remove, $"Remove pane {index + 1}"); - remove.Click += (_, _) => DispatcherQueue.TryEnqueue(() => RemovePane(pane)); - - var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - row.Children.Add(ordinal); - row.Children.Add(label); - row.Children.Add(picker); - row.Children.Add(remove); - - return new Border - { - Child = row, - Padding = new Thickness(9, 3, 5, 3), - CornerRadius = new CornerRadius(12), - BorderThickness = new Thickness(1), - // The active pane is outlined, matching how the tab strip marks the selected agent. - BorderBrush = isActive ? accent : border, - Background = new SolidColorBrush(Colors.Transparent), - }; - } - /// Keeps the pane set valid after the agent set changes. Panes whose agent was /// closed drop out; falling below two panes turns the split off entirely. private void ValidateSplit() @@ -589,6 +952,12 @@ private void RefreshTabStrip() foreach (var tab in _tabs) { + // An agent already on screen does not also need a tab. Its name is on the pane header + // now, so the strip goes back to being a list of what is NOT currently visible — which + // is also what makes it obvious that dragging one in is the way to show it. + var paned = SplitActive && _splitPanes.Any(p => ReferenceEquals(p, tab)); + tab.Header.Visibility = paned ? Visibility.Collapsed : Visibility.Visible; + var isSelected = ReferenceEquals(tab, _selected); tab.Header.Background = isSelected ? background : transparent; tab.Header.BorderBrush = isSelected ? accent : border; @@ -634,7 +1003,11 @@ private void RefreshTabStrip() private void LayoutTabStrip() { - int count = _tabs.Count; + // Paned agents have no tab in the strip, so they must not be counted when dividing up the + // width — otherwise the tabs that ARE shown come out narrower than they need to be, and a + // 4-pane split would squeeze the remainder for no reason. + var shown = _tabs.Where(t => t.Header.Visibility == Visibility.Visible).ToList(); + int count = shown.Count; if (count == 0) return; // The visible strip is the scroller's viewport; a later SizeChanged fixes up the first @@ -646,7 +1019,7 @@ private void LayoutTabStrip() 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) + foreach (var tab in shown) tab.Header.Width = per; } diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index b3be228..043dc5c 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -895,20 +895,16 @@ - - - - - - - - - + + + + + +