From 1dfb12ee6baff36351ada9b51d338ae67bad077c Mon Sep 17 00:00:00 2001 From: DevMando Date: Mon, 7 Sep 2026 16:53:08 -0700 Subject: [PATCH 1/2] Put the models you use at the top of the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Settings sat on an empty model picker while two network round-trips ran (a probe, then /api/tags), even though the configured model was already known from config. It is now seeded from config on the first frame and the full list fills in behind it, the way LoadSnapshotModelsAsync already does for the snapshot card. A failed or empty fetch keeps the picker as it was rather than assigning an empty list over a control that was showing the right answer, and a configured model the fetch omits — a cloud model with nothing pulled locally — stays listed, since it is what the agent is actually running. ModelOrdering then arranges the list: pinned first, then recently used, then everything else alphabetically. Recency of USE rather than of pull — a model tried once and abandoned would otherwise outrank the one running all day, and cloud models are never pulled locally so they carry no useful pull date. This also avoids plumbing modified_at out of the engine's /api/tags parsing. The tail stays alphabetical deliberately: an order that reshuffles whenever a model is pulled destroys the muscle memory that makes a long dropdown usable. Pins and recents live in panel-state.json beside the other window-level preferences rather than in per-agent config, since "these are my models" describes the person, not one agent. Both fields are optional so existing panel-state files still load. The picker's items stay plain strings so the editable ComboBox keeps showing the model name and the existing IndexOf/Text logic is untouched; the pin rides in an ItemTemplate over the string. The pin is handled on Tapped rather than Click so the tap does not bubble to the ComboBoxItem and get treated as picking that model. Also switches the preview pane's title icon to a globe while the browser is showing — the same glyph as the toolbar button that opens it — instead of the document icon it used for everything. --- CHANGELOG.md | 13 ++- .../MandoCode.Desktop.Tests.csproj | 1 + .../ModelOrderingTests.cs | 108 ++++++++++++++++++ .../Controls/ChatTabView.Explorer.cs | 8 +- .../Controls/ChatTabView.xaml | 2 +- .../MainWindow.Appearance.cs | 54 ++++++++- src/MandoCode.Desktop/MainWindow.Snapshots.cs | 3 +- src/MandoCode.Desktop/MainWindow.xaml | 25 +++- src/MandoCode.Desktop/MainWindow.xaml.cs | 4 + .../Services/ModelOrdering.cs | 86 ++++++++++++++ src/MandoCode.Desktop/Services/PanelState.cs | 4 +- .../Services/PinnedOpacityConverter.cs | 17 +++ .../ViewModels/ChatController.cs | 7 +- 13 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/ModelOrderingTests.cs create mode 100644 src/MandoCode.Desktop/Services/ModelOrdering.cs create mode 100644 src/MandoCode.Desktop/Services/PinnedOpacityConverter.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7608747..dee157d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Pinned and recently used models rise to the top of the model picker.** A pin on each row in + Settings keeps the models you actually use at the top; below them sit the models you most + recently switched to, then everything else alphabetically. Pins and recent use are remembered + across launches and apply to every agent. The tail stays alphabetical on purpose, so a long + list does not reshuffle every time a model is pulled. - **Embedded form DOM support.** Browser tools discover cross-origin and nested frames and can inspect, fill, select, scroll, wait, and read back fields using explicit tab and frame IDs. Navigated or removed frame targets fail without falling back to the parent. @@ -112,6 +117,12 @@ for every approved plan. Desktop's version follows the engine generation, so it plan runner, manual conversation compaction, automatic planning based on task shape, and the large-root context guard verified through Desktop against a real `@directory` request. +- **The Settings model picker no longer waits on the network to show your model.** The configured + model appears selected immediately and the installed-model list fills in behind it. A failed or + empty fetch now keeps the picker as it was rather than blanking it, and a configured model the + fetch does not return — a cloud model with nothing pulled locally — stays listed. +- **The preview pane shows a globe when it is showing the browser.** It previously showed a + document icon whether the pane held a file or a live web page. - **Model status is now one line instead of several cards.** The active model, its image capability, and whether it runs in the cloud appear together as `model · active · text-only · cloud`, replacing the separate capability notice and status pill. The cloud subscription caveat @@ -142,7 +153,7 @@ for every approved plan. Desktop's version follows the engine generation, so it not conversation messages, so stale actions are not replayed into a restored session. ### Test coverage -295 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction +303 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction editing, dependent-step revision, checkpoint cards, Resume/Discard actions, semantic step outcomes, and truthful completion status. Browser coverage adds explicit tab targeting, frame identity, and plan-card review content. The same workflows were also exercised with real models, including diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 4e7c6e0..19df441 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/src/MandoCode.Desktop.Tests/ModelOrderingTests.cs b/src/MandoCode.Desktop.Tests/ModelOrderingTests.cs new file mode 100644 index 0000000..5a532e4 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/ModelOrderingTests.cs @@ -0,0 +1,108 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// ModelOrdering holds process-wide state, so every test states its own starting point. +public sealed class ModelOrderingTests +{ + private static readonly string[] Installed = + ["zephyr:7b", "qwen2.5-coder:14b", "deepseek-v4-flash:cloud", "llama3:8b", "minimax-m3:cloud"]; + + [Fact] + public void PinnedComeFirstThenRecentThenAlphabetical() + { + ModelOrdering.Load(pinned: ["minimax-m3:cloud"], recent: ["llama3:8b", "zephyr:7b"]); + + Assert.Equal( + ["minimax-m3:cloud", "llama3:8b", "zephyr:7b", "deepseek-v4-flash:cloud", "qwen2.5-coder:14b"], + ModelOrdering.Arrange(Installed)); + } + + [Fact] + public void APinnedModelIsNotAlsoListedUnderRecent() + { + ModelOrdering.Load(pinned: ["llama3:8b"], recent: ["llama3:8b", "zephyr:7b"]); + + var ordered = ModelOrdering.Arrange(Installed); + + Assert.Equal("llama3:8b", ordered[0]); + Assert.Single(ordered, m => m == "llama3:8b"); + Assert.Equal(Installed.Length, ordered.Count); + } + + [Fact] + public void PinnedOrRecentModelsThatAreGoneAreSkipped() + { + ModelOrdering.Load(pinned: ["uninstalled:70b"], recent: ["also-gone:3b", "zephyr:7b"]); + + var ordered = ModelOrdering.Arrange(Installed); + + Assert.Equal("zephyr:7b", ordered[0]); + Assert.Equal(Installed.Length, ordered.Count); + Assert.DoesNotContain("uninstalled:70b", ordered); + } + + [Fact] + public void TheTailStaysAlphabeticalSoTheListDoesNotReshuffle() + { + ModelOrdering.Load(null, null); + + Assert.Equal( + ["deepseek-v4-flash:cloud", "llama3:8b", "minimax-m3:cloud", "qwen2.5-coder:14b", "zephyr:7b"], + ModelOrdering.Arrange(Installed)); + } + + [Fact] + public void UseMovesAModelToTheFrontWithoutDuplicatingIt() + { + ModelOrdering.Load(null, recent: ["llama3:8b", "zephyr:7b"]); + + ModelOrdering.NoteUsed("zephyr:7b"); + + Assert.Equal(["zephyr:7b", "llama3:8b"], ModelOrdering.Recent); + } + + [Fact] + public void RecentIsBoundedSoItStaysAShortlist() + { + ModelOrdering.Load(null, null); + + foreach (var model in new[] { "a", "b", "c", "d", "e", "f", "g" }) ModelOrdering.NoteUsed(model); + + Assert.Equal(ModelOrdering.MaxRecent, ModelOrdering.Recent.Count); + Assert.Equal("g", ModelOrdering.Recent[0]); + Assert.DoesNotContain("a", ModelOrdering.Recent); + } + + [Fact] + public void PinTogglesOffAndMatchingIgnoresCase() + { + ModelOrdering.Load(pinned: ["Llama3:8B"], recent: null); + Assert.True(ModelOrdering.IsPinned("llama3:8b")); + + ModelOrdering.TogglePin("llama3:8b"); + Assert.False(ModelOrdering.IsPinned("Llama3:8B")); + Assert.Empty(ModelOrdering.Pinned); + } + + [Fact] + public void BlankAndUnchangedInputAreIgnored() + { + ModelOrdering.Load(null, recent: ["llama3:8b"]); + var changes = 0; + void Count() => changes++; + ModelOrdering.Changed += Count; + try + { + ModelOrdering.NoteUsed(null); + ModelOrdering.NoteUsed(" "); + ModelOrdering.NoteUsed("llama3:8b"); // already on top + ModelOrdering.TogglePin(""); + } + finally { ModelOrdering.Changed -= Count; } + + Assert.Equal(0, changes); + Assert.Equal(["llama3:8b"], ModelOrdering.Recent); + } +} diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs index 0562efc..f3218dd 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs @@ -602,7 +602,13 @@ private void ResetPreviewEditing() UpdatePreviewTitle(); } - private void UpdatePreviewTitle() => PreviewTitleText.Text = _previewDirty ? $"{_previewTitle} • unsaved" : _previewTitle; + private void UpdatePreviewTitle() + { + PreviewTitleText.Text = _previewDirty ? $"{_previewTitle} • unsaved" : _previewTitle; + // The browser gets the same globe as the toolbar button that opens it, so the pane and its + // control read as one feature. A document icon over a live web page describes the wrong thing. + PreviewTitleIcon.Glyph = _browserPreview ? "\uE774" : "\uE8A5"; + } private async Task ConfirmDiscardPreviewChangesAsync() { diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index 7fa2d36..c183a2f 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -175,7 +175,7 @@ - diff --git a/src/MandoCode.Desktop/MainWindow.Appearance.cs b/src/MandoCode.Desktop/MainWindow.Appearance.cs index 1c9fe03..c5013d3 100644 --- a/src/MandoCode.Desktop/MainWindow.Appearance.cs +++ b/src/MandoCode.Desktop/MainWindow.Appearance.cs @@ -237,16 +237,62 @@ private async void TavilySave_Click(object sender, RoutedEventArgs e) private async void RefreshModels_Click(object sender, RoutedEventArgs e) => await RefreshModelListAsync(); + /// + /// Pin toggle inside a dropdown row. Handled on Tapped rather than Click so the tap stops here + /// instead of bubbling to the ComboBoxItem, which would otherwise treat pinning as picking the + /// model and close the dropdown on the way out. + /// + private void ModelPin_Tapped(object sender, TappedRoutedEventArgs e) + { + e.Handled = true; + if (sender is not FrameworkElement { Tag: string model } || string.IsNullOrWhiteSpace(model)) return; + ModelOrdering.TogglePin(model); + + // Re-project the same names so the row's glyph re-renders and the pinned model moves up. + if (ModelCombo.ItemsSource is not IList current) return; + if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; + ModelCombo.ItemsSource = ModelOrdering.Arrange(current); + ApplyModelComboTarget(); + } + + /// Fills the model picker without making the page wait on the network: the configured + /// model is already known, so it is shown selected on the first frame, then the installed-model + /// list (a probe plus an Ollama /api/tags fetch, slow on cloud setups) fills in behind it for + /// "pick another". Same approach as LoadSnapshotModelsAsync. private async Task RefreshModelListAsync() { + // Instant: seed with the one model we already know, so the picker never sits empty. Only on + // a first open — a manual refresh keeps the list it has until the new one arrives. + var configured = _controller.Config.GetEffectiveModelName(); + if (!string.IsNullOrEmpty(configured) && + (ModelCombo.ItemsSource is not IList present || present.Count == 0)) + { + _modelComboTarget = configured; + ModelCombo.ItemsSource = new List { configured }; + ApplyModelComboTarget(); + } + ModelListStatus.Text = "Fetching models…"; var models = await Task.Run(_controller.ListModelsAsync); if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text; - ModelCombo.ItemsSource = models; + + // A failed or empty fetch keeps whatever is already selectable. Replacing it with an empty + // list would blank a picker that was showing the right answer a moment ago. + if (models.Count == 0) + { + ModelListStatus.Text = "No models found — is Ollama running? (ollama serve, then ollama pull )"; + return; + } + + // A configured model the fetch doesn't list (a cloud model with nothing pulled locally) + // still belongs in the picker — it is what the agent is actually using. + if (!string.IsNullOrEmpty(_modelComboTarget) && + !models.Any(m => string.Equals(m, _modelComboTarget, StringComparison.OrdinalIgnoreCase))) + models.Insert(0, _modelComboTarget); + + ModelCombo.ItemsSource = ModelOrdering.Arrange(models); ApplyModelComboTarget(); - ModelListStatus.Text = models.Count == 0 - ? "No models found — is Ollama running? (ollama serve, then ollama pull )" - : $"{models.Count} model(s) available."; + ModelListStatus.Text = $"{models.Count} model(s) available."; } private async void SettingsSave_Click(object sender, RoutedEventArgs e) diff --git a/src/MandoCode.Desktop/MainWindow.Snapshots.cs b/src/MandoCode.Desktop/MainWindow.Snapshots.cs index 0596e8e..1c405b9 100644 --- a/src/MandoCode.Desktop/MainWindow.Snapshots.cs +++ b/src/MandoCode.Desktop/MainWindow.Snapshots.cs @@ -142,7 +142,8 @@ private void SavePanelState() => PanelState.Save(new PanelStateShape( _collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(), _snapshotsSeenAt, _historySeenAt, _collapsedNoteGroups.ToList(), _lastNotePath, _noteModel, - AgentCallsigns.Enabled)); + AgentCallsigns.Enabled, + ModelOrdering.Pinned.ToList(), ModelOrdering.Recent.ToList())); // The group object is kept in sync (not just the set) so that when the ListView recycles a // container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state. diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index 7413b01..bb02adb 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -1003,7 +1003,30 @@ + ToolTipService.ToolTip="The model that powers every response. Bigger models reason better but respond slower and need more memory; coder-tuned models follow tool-calling instructions most reliably."> + + + + + + + + + + + + + + + +