From 20195407a30095aa621c38622abbe554abca4bae Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Mon, 20 Jul 2026 19:48:04 -0700 Subject: [PATCH] Improve agent tab strip and transcript readability Usability polish for the agent tab strip and the chat transcript. Tab strip: - Tabs size comfortably when there's room and only shrink as more agents open, then scroll (mouse wheel / scrollbar) past that. - Scrollbar sits in its own lane so it no longer overlaps the tabs. - Added brand/tabs/add-button dividers and aligned the header. - The "add agent" action is now a permanent, labeled button pinned far right, so it never scrolls out of reach. - Creating or selecting an agent scrolls that tab fully into view. - Renamed the tab menu item "Close tab" to "Close agent". Transcript: - Long diff/output panels collapse to a short preview with an Expand/Collapse control, so large writes don't force endless scrolling. - Web search/fetch result previews are hidden behind an Expand toggle to reduce noise. - Approval/plan diff colors now follow the active theme instead of a hardcoded blue. --- .../Controls/ChatTabView.xaml.cs | 22 +++- src/MandoCode.Desktop/MainWindow.xaml | 61 ++++++--- src/MandoCode.Desktop/MainWindow.xaml.cs | 90 ++++++++++++- .../Services/TranscriptHtmlBuilder.cs | 120 +++++++++++++++++- 4 files changed, 261 insertions(+), 32 deletions(-) diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index 345b8ea..3579cb6 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -1030,34 +1030,42 @@ public Task 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(); 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; diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index 5e15690..7d33188 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -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. --> - + - - + + + + + + - - - - - - + Margin="0,0,0,18" Foreground="{StaticResource MandoAccentBrush}"/> + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 5fa6882..2aaebc0 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -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. @@ -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); @@ -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(); @@ -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); @@ -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); @@ -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) diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index 210fbfc..1d3e227 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -202,11 +202,27 @@ public string OperationCard(OperationDisplayEvent op) // so they recede as reference material instead of a highlighted code block. File // content previews (Read) stay monospace/no-wrap since they really are code. var prose = op.OperationType is "WebSearch" or "WebFetch"; - var detailCls = prose ? "cmd-out op-detail op-prose" : "cmd-out op-detail"; - sb.Append($"
{E(op.ContentPreview)}");
-                if (op.RemainingLines > 0)
-                    sb.Append($"\n… +{op.RemainingLines} more lines");
-                sb.Append("
"); + if (prose) + { + // Web dumps are noisy reference material almost no one reads inline, so hide the + // preview behind an "Expand" chip on the op line. Expanding reveals the detail + // box, which carries its own "Collapse" button so it can be closed from the + // window too. Toggling is wired in the transcript's web-toggle click handler. + sb.Append(""); + sb.Append(""); + } + else + { + sb.Append($"
{E(op.ContentPreview)}");
+                    if (op.RemainingLines > 0)
+                        sb.Append($"\n… +{op.RemainingLines} more lines");
+                    sb.Append("
"); + } } } @@ -421,6 +437,44 @@ chips still grow past it. */ .d-add { color: var(--diffadd); display: block; } .d-rem { color: var(--red); display: block; } .d-ctx { color: var(--dim); display: block; } + + /* Collapsible long panels: a big write/diff/output otherwise fills the screen and forces + endless scrolling, so panel-hosted blocks taller than ~22% of the window collapse to that + preview height by default. A matching Expand/Collapse button sits in the top-RIGHT and + bottom-RIGHT corners (JS adds them only when a block is actually tall) so it's reachable + whether you're at the top or, after expanding, down at the bottom. The header and footer + pad on the right to clear the buttons. Pure class flip on click — no animation loop. */ + .collapsible-panel { position: relative; } + .collapsible-panel > .panel-header, + .collapsible-panel > .panel-footer { + padding-right: 84px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + /* Reserve a bottom gutter so the bottom corner buttons never overlap the last line of a + footerless panel (e.g. command output). */ + pre.collapsible { position: relative; padding-bottom: 34px; } + pre.collapsible.collapsed { max-height: 22vh; overflow-y: hidden; } + .collapse-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 44px; + pointer-events: none; background: linear-gradient(to bottom, transparent, var(--panel)); } + .expand-btn { position: absolute; top: 6px; z-index: 3; cursor: pointer; + background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 2px 9px; font-size: 11px; + font-family: "Segoe UI", sans-serif; opacity: 0.9; } + .expand-btn.left { left: 6px; } + .expand-btn.right { right: 6px; } + .expand-btn.bottom { top: auto; bottom: 6px; } + .expand-btn:hover { color: var(--fg); border-color: var(--accent); opacity: 1; } + + /* Web fetch/search previews: noisy reference text, hidden by default behind an inline Expand + chip on the op line. Expanding reveals the detail box, which reuses the corner Collapse + button (.expand-btn.right) so it can be closed from the window itself. */ + .web-toggle { margin-left: 8px; cursor: pointer; vertical-align: baseline; + background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 1px 8px; font-size: 11px; font-family: "Segoe UI", sans-serif; } + .web-toggle:hover { color: var(--fg); border-color: var(--accent); } + .web-detail { position: relative; margin-top: 4px; } + .web-detail[hidden] { display: none; } + .web-detail > .op-detail { margin-top: 0; } a.file-link { color: var(--sky); text-decoration: none; border-bottom: 1px dotted color-mix(in srgb, var(--sky) 55%, transparent); cursor: pointer; } a.file-link:hover { color: var(--accent); border-bottom-color: var(--accent); } @@ -772,6 +826,45 @@ function addReactionGhosts() { }); } + // --- collapse long diff/output panels to a preview; corner buttons maximize/minimize --- + // Only panel-hosted
 blocks (diffs, command output, folder-delete listings) taller than
+  // ~22% of the window get collapsed. A matching Expand/Collapse button is placed in the top-RIGHT
+  // and bottom-RIGHT corners so it's reachable from the top or — after expanding down — the bottom.
+  function setCollapsed(pre, collapsed) {
+    pre.classList.toggle('collapsed', collapsed);
+    const panel = pre.closest('.panel');
+    if (!panel) return;
+    const fade = panel.querySelector('.collapse-fade');
+    if (fade) fade.style.display = collapsed ? 'block' : 'none';
+    panel.querySelectorAll('.expand-btn').forEach(function (b) {
+      b.textContent = collapsed ? '⤢ Expand' : '⤡ Collapse';
+    });
+  }
+  function addCollapsers() {
+    log.querySelectorAll('pre.diff:not([data-collapse]), pre.cmd-out:not([data-collapse])').forEach(function (pre) {
+      pre.setAttribute('data-collapse', '1');
+      const panel = pre.closest('.panel');
+      if (!panel) return;                                               // only panel-hosted blocks
+      if (pre.scrollHeight <= window.innerHeight * 0.22 + 40) return;   // short enough already
+      pre.classList.add('collapsible');
+      panel.classList.add('collapsible-panel');
+      const fade = document.createElement('div');
+      fade.className = 'collapse-fade';
+      pre.appendChild(fade);
+      ['right', 'right bottom'].forEach(function (side) {
+        const b = document.createElement('button');
+        b.className = 'expand-btn ' + side;
+        b.title = 'Maximize / minimize this block';
+        b.addEventListener('click', function (ev) {
+          ev.stopPropagation();
+          setCollapsed(pre, !pre.classList.contains('collapsed'));
+        });
+        panel.appendChild(b);
+      });
+      setCollapsed(pre, true);                                          // start minimized
+    });
+  }
+
   window.__append = function (html) {
     const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 60);
     const wrap = document.createElement('div');
@@ -780,6 +873,7 @@ function addReactionGhosts() {
     highlightNew();
     addCopyChips();
     addReactionGhosts();
+    addCollapsers();
     if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
     updatePill();
   };
@@ -792,6 +886,22 @@ function addReactionGhosts() {
     window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file'));
   });
 
+  // Web fetch/search preview toggle: the inline chip opens the hidden detail box; the box's own
+  // Collapse button (and the chip again) closes it. Chip label and box visibility stay in sync.
+  document.addEventListener('click', function (e) {
+    const t = e.target.closest('.web-toggle, .web-collapse');
+    if (!t) return;
+    e.stopPropagation();
+    const op = t.closest('.op');
+    if (!op) return;
+    const detail = op.querySelector('.web-detail');
+    const toggle = op.querySelector('.web-toggle');
+    if (!detail || !toggle) return;
+    const open = t.classList.contains('web-collapse') ? false : detail.hasAttribute('hidden');
+    detail.toggleAttribute('hidden', !open);
+    toggle.textContent = open ? '⤡ Collapse' : '⤢ Expand';
+  });
+
   // --- jump-to-bottom pill ---
   const pill = document.createElement('div');
   pill.id = 'jump-pill';