From 01e49eebca8f751019ce262789c1882cd37cf362 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Wed, 15 Jul 2026 18:23:38 -0700 Subject: [PATCH] Rework snapshot, plan-approval, and settings UX Snapshot workflow: - Offer to create a snapshot now slides in from the top of the chat as a thin, non-blocking notification you can ignore and keep prompting; clicking Create expands it into the name + model picker in place. - Creating shows a clear busy state (fade + spinner + "Creating snapshot"). - Snapshot cards: camera icon, timestamp + message count, name shown on Import. Plan approval: - Proposed plans no longer open a modal that hides the plan. The plan stays readable in the transcript and a bar slides up from the bottom with three clearly distinct, color-coded actions: Execute plan / One-shot it / Cancel request (with a tooltip explaining "one-shot"). Input is gated until you choose. Settings: - Consolidated 6 tabs into 4 (Model, Behavior, Integrations + Appearance moved to its own rail page since it's app-wide, not per-agent). - MCP enable toggle moved onto the MCP page so all MCP config lives in one place. - Per-tab "Reset to factory defaults" button. - Fixed: clearing a numeric field (e.g. Max response tokens) left it stuck empty and broke the spin buttons; it now snaps back and all numeric fields have floors. Polish: - New "Phosphor Fwog" theme. - Microcopy pass (clearer MCP/snapshot descriptions, de-jargoned CLI leaks). - Uniform min-width for status pills; consistent rail tooltips. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controls/ChatTabView.xaml | 165 ++++++++++----- .../Controls/ChatTabView.xaml.cs | 183 ++++++++++++++-- src/MandoCode.Desktop/MainWindow.xaml | 200 +++++++++++------- src/MandoCode.Desktop/MainWindow.xaml.cs | 93 ++++++-- .../Services/ApprovalModels.cs | 9 +- .../Services/ThemeManager.cs | 12 +- .../Services/TranscriptHtmlBuilder.cs | 6 +- .../ViewModels/ChatController.cs | 17 +- 8 files changed, 516 insertions(+), 169 deletions(-) diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index 81f9283..d628172 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -130,59 +130,126 @@ - - - - - - - - - - - - - - + Slides down from the top of the transcript when a model switch (or "Take snapshot") + buffers a conversation. Two stages: a thin notification bar, then (on Create) the full + name + model picker. Non-blocking — keep prompting; it persists until you create or + dismiss. Grid.Row="1" + Top alignment floats it over the top of the WebView transcript; + declared after it so it layers on top. --> + + + + + + + + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -437,11 +474,16 @@ - + - + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index e418b9f..3e7eab0 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -106,7 +106,7 @@ public MainWindow() // ONE window-level subscription to the static ThemeChanged event. Chat tabs must not // subscribe individually — the handler would outlive every closed tab and leak. ThemeManager.ThemeChanged += () => OnUi(ApplyThemeToAllTabs); - SettingsTabs.SelectedItem = Tab_Connection; // the setup that matters most opens first + SettingsTabs.SelectedItem = Tab_Model; // the setup that matters most opens first ThemeList.ItemsSource = UiTheme.All.Select(t => new ThemeVm { Theme = t }).ToList(); ModelCombo.Loaded += (_, _) => ApplyModelComboTarget(); S_WindowOpacity.Value = ThemeManager.WindowOpacity * 100; @@ -253,6 +253,8 @@ private void NavSettings_Click(object sender, RoutedEventArgs e) => SwitchPage(_currentPage == "settings" ? "chat" : "settings"); private void NavMcp_Click(object sender, RoutedEventArgs e) => SwitchPage(_currentPage == "mcp" ? "chat" : "mcp"); + private void NavAppearance_Click(object sender, RoutedEventArgs e) + => SwitchPage(_currentPage == "appearance" ? "chat" : "appearance"); private void SwitchPage(string page) { @@ -261,11 +263,13 @@ private void SwitchPage(string page) SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed; McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed; + AppearancePage.Visibility = page == "appearance" ? Visibility.Visible : Visibility.Collapsed; // Glide the full-screen page in from the rail side (translate + fade). Both run on the // composition thread, so the whole page slides smoothly regardless of how much it holds. if (page == "settings") SlideInPage(SettingsPage, SettingsPageTransform); else if (page == "mcp") SlideInPage(McpPage, McpPageTransform); + else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform); // Every agent view stays loaded; only the selected one shows, and only on the chat page. // Collapsing rather than removing is what keeps each WebView2's transcript alive. @@ -336,6 +340,7 @@ private void RefreshNavIcons() NavChatIcon.Foreground = _currentPage == "chat" ? accent : (approvalPending ? gold : normal); NavSettingsIcon.Foreground = _currentPage == "settings" ? accent : normal; NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal; + NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal; NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal; ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents"); } @@ -454,6 +459,55 @@ private void MakeDefault_Click(object sender, RoutedEventArgs e) + "Agents already open keep their own."; } + /// Resets the visible tab's settings to the app's factory defaults (this agent, this + /// session). Reads a fresh for the defaults and applies each key + /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the + /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset. + private async void ResetTab_Click(object sender, RoutedEventArgs e) + { + var d = new MandoCodeConfig(); // factory defaults (property initializers) + var s = SettingsTabs.SelectedItem; + var resets = new List<(string Key, string Value)>(); + string tabName; + + static string Bool(bool b) => b ? "true" : "false"; + static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (s == Tab_Behavior) + { + tabName = "Behavior"; + resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning))); + resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals))); + resets.Add(("autoContinue", Bool(d.EnableAutoContinuation))); + resets.Add(("maxContinuations", Num(d.MaxAutoContinuations))); + resets.Add(("timeout", Num(d.RequestTimeoutMinutes))); + resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds))); + resets.Add(("toolBudget", Num(d.ToolResultCharBudget))); + resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds))); + } + else if (s == Tab_Integrations) + { + tabName = "Integrations"; + resets.Add(("webSearch", Bool(d.EnableWebSearch))); + } + else + { + tabName = "Model"; + resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture))); + resets.Add(("maxTokens", Num(d.MaxTokens))); + resets.Add(("contextLength", Num(d.ContextLength))); + resets.Add(("streaming", d.ResponseStreaming)); + } + + ResetTabButton.IsEnabled = false; + foreach (var (key, value) in resets) + await _controller.ApplyConfigKeyAsync(key, value); + ResetTabButton.IsEnabled = true; + + LoadSettings(); // reflect the restored values (also clears the status line) + SettingsStatus.Text = $"{tabName} settings reset to factory defaults."; + } + private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title) { var label = new TextBlock @@ -520,7 +574,7 @@ private void WireHeader(ChatTabEntry entry) var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } }; rename.Click += (_, _) => _ = RenameTabAsync(entry); - var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } }; + var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } }; snapshot.Click += (_, _) => entry.View.TakeSnapshotManually(); var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } }; @@ -696,7 +750,6 @@ private void LoadSettings() S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden; TavilyViewButton.Content = "View"; TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey); - S_Mcp.IsOn = cfg.EnableMcp; for (int i = 0; i < UiTheme.All.Count; i++) if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i; SettingsStatus.Text = ""; @@ -710,16 +763,15 @@ private void LoadSettings() private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) { var s = sender.SelectedItem; - TabPanel_Connection.Visibility = s == Tab_Connection ? Visibility.Visible : Visibility.Collapsed; - TabPanel_Generation.Visibility = s == Tab_Generation ? Visibility.Visible : Visibility.Collapsed; + TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed; TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed; - TabPanel_Limits.Visibility = s == Tab_Limits ? Visibility.Visible : Visibility.Collapsed; TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed; - TabPanel_Appearance.Visibility = s == Tab_Appearance ? Visibility.Visible : Visibility.Collapsed; - // Appearance is app-wide (a window property), not a per-agent setting, so "Make Default for - // New Agents" has nothing to save there — hide it on that tab to avoid a no-op button. - MakeDefaultButton.Visibility = s == Tab_Appearance ? Visibility.Collapsed : Visibility.Visible; + // "Reset" acts on the visible tab, so its label names that tab. + ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior" + : s == Tab_Integrations ? "Reset Integrations" : "Reset Model"; + // Every remaining tab is per-agent now (Appearance moved to its own rail page), so + // "Make Default for New Agents" always applies. } private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) @@ -807,7 +859,18 @@ private async void Setting_Toggled(object sender, RoutedEventArgs e) private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) { - if (_loadingSettings || double.IsNaN(args.NewValue)) return; + if (_loadingSettings) return; + + // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and + // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons + // keep working. If even the old value is gone, reload the whole form from config. + if (double.IsNaN(args.NewValue)) + { + if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue; + else LoadSettings(); + return; + } + await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString()); } @@ -882,6 +945,12 @@ private async void SettingsSave_Click(object sender, RoutedEventArgs e) private async Task RefreshMcpListAsync() { + // Reflect the active agent's per-agent MCP opt-in (the servers themselves are app-wide). + // Guard so setting IsOn programmatically doesn't fire Setting_Toggled and re-apply it. + _loadingSettings = true; + McpEnableToggle.IsOn = _controller.Config.EnableMcp; + _loadingSettings = false; + McpPageStatus.Text = "Checking server status…"; var rows = await Task.Run(_controller.GetMcpStatusRowsAsync); @@ -896,7 +965,7 @@ private async Task RefreshMcpListAsync() }).ToList(); McpPageStatus.Text = rows.Count == 0 - ? "No MCP servers configured yet — add one below. Same config file as the CLI." + ? "No MCP servers configured yet — add one below." : $"{rows.Count} server(s) configured."; } diff --git a/src/MandoCode.Desktop/Services/ApprovalModels.cs b/src/MandoCode.Desktop/Services/ApprovalModels.cs index 92748d5..1b550ca 100644 --- a/src/MandoCode.Desktop/Services/ApprovalModels.cs +++ b/src/MandoCode.Desktop/Services/ApprovalModels.cs @@ -13,8 +13,9 @@ public enum ApprovalOptionKind Destructive } -/// Glyph is an optional Segoe Fluent icon rendered before the label. -public sealed record ApprovalOption(string Label, ApprovalOptionKind Kind, string? Glyph = null); +/// Glyph is an optional Segoe Fluent icon rendered before the label; Description, when set, +/// is shown as a hover tooltip explaining what the option does. +public sealed record ApprovalOption(string Label, ApprovalOptionKind Kind, string? Glyph = null, string? Description = null); /// /// Everything the approval overlay needs to render one approval prompt. @@ -41,6 +42,10 @@ public sealed class ApprovalRequest public string? DiffSummary { get; init; } public required IReadOnlyList Options { get; init; } + + /// Render as a non-covering bottom bar rather than the centered modal. Used for plan + /// approval so the plan card stays readable in the transcript while the user decides. + public bool BottomBar { get; init; } } /// diff --git a/src/MandoCode.Desktop/Services/ThemeManager.cs b/src/MandoCode.Desktop/Services/ThemeManager.cs index 9fd2d16..ae9ee3c 100644 --- a/src/MandoCode.Desktop/Services/ThemeManager.cs +++ b/src/MandoCode.Desktop/Services/ThemeManager.cs @@ -59,12 +59,12 @@ public sealed record UiTheme }, new UiTheme { - Name = "Phosphor", - Description = "Green-on-black terminal. Old-school hacker energy.", - Background = "#0A0F0A", Panel = "#111B11", Border = "#234023", - Text = "#CDEFCD", Dim = "#6F8F6F", - Accent = "#3DFF7B", Gold = "#D9FF66", Sky = "#43E0C8", - Green = "#3DFF7B", Red = "#FF6259", DiffAdd = "#A8FF60", + Name = "Phosphor Fwog", + Description = "Tree-fwog green on a dark pond — Phosphor's terminal roots, but froggier. 🐸", + Background = "#0B1410", Panel = "#15211A", Border = "#294A34", + Text = "#D8F3CF", Dim = "#7FA383", + Accent = "#63D94B", Gold = "#FFCB47", Sky = "#4FD6C2", + Green = "#63D94B", Red = "#FF6F5B", DiffAdd = "#A7E86A", }, new UiTheme { diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index 9ebcf22..1c8a284 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -286,7 +286,11 @@ .md pre { .chip-row { margin: 2px 0; } .chip { display: inline-flex; align-items: center; gap: 7px; padding: 3px 12px; border-radius: 999px; font-size: 12.5px; - border: 1px solid var(--border); background: var(--panel); } + border: 1px solid var(--border); background: var(--panel); + /* Uniform floor so status pills line up — the "MCP / N connected" pill is the + widest of them, so shorter pills (model / ready) pad up to match. Longer + chips still grow past it. */ + box-sizing: border-box; min-width: 190px; } .chip .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dim); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); } .chip.ok .dot { background: var(--green); diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 346dcc9..1c6f343 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -650,7 +650,7 @@ private void OnFunctionCompleted(FunctionExecutionResult result) // ============================================================ private const string ExecutePlanLabel = "Execute plan"; - private const string RejectPlanLabel = "Reject (answer without a plan)"; + private const string RejectPlanLabel = "One-shot it"; private const string CancelRequestLabel = "Cancel request"; private const string SkipStepLabel = "Skip this step and continue"; private const string CancelPlanLabel = "Cancel the plan"; @@ -670,11 +670,15 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo choice = await ui.ShowApprovalAsync(new ApprovalRequest { Title = "The assistant proposes this plan. What would you like to do?", + // Bottom bar, not the centered modal — the plan card above stays readable. + BottomBar = true, Options = new[] { new ApprovalOption(ExecutePlanLabel, ApprovalOptionKind.Proceed), - new ApprovalOption(RejectPlanLabel, ApprovalOptionKind.Redirect), - new ApprovalOption(CancelRequestLabel, ApprovalOptionKind.Redirect) + new ApprovalOption(RejectPlanLabel, ApprovalOptionKind.Redirect, + Description: "Skip the step-by-step plan — the model attempts the whole request in one shot."), + // Destructive (red) so the hard "stop" reads differently from "one-shot it". + new ApprovalOption(CancelRequestLabel, ApprovalOptionKind.Destructive) } }, ct); @@ -683,7 +687,7 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo if (choice == CancelRequestLabel) { - _transcript.Append(_html.Dim("Plan cancelled.")); + _transcript.Append(_html.Dim("Request cancelled — stopping here.")); // Cancel the request token so the turn mechanically unwinds — the return // string alone is a polite request small models ignore (see App.razor). _requestCts?.Cancel(); @@ -692,8 +696,9 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo if (choice == RejectPlanLabel) { - _transcript.Append(_html.Dim("Plan rejected — continuing without stepwise execution.")); - return "User rejected the proposed plan. Respond to the original request directly without calling propose_plan again."; + _transcript.Append(_html.Dim("Plan skipped — one-shotting your request.")); + return "User declined the step-by-step plan and wants you to one-shot it: attempt the full " + + "request in a single pass. Do not call propose_plan again."; } _transcript.Append(_html.Success("Executing plan..."));