diff --git a/CHANGELOG.md b/CHANGELOG.md index 7608747..636c703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,25 @@ 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. +- **An interactive browser preview the agent can drive.** The agent reads a page's text, + controls, values, and browser errors, then clicks, hovers, presses keys, fills fields, selects + options, scrolls, and waits for elements. Pointer and keyboard input are dispatched as genuine + browser events, so a page cannot tell them from a person. The preview's cache is bypassed and + refreshes ask for a cache-bypassing reload, so an edited script or stylesheet is never reviewed + against its previous version and project files need no cache-busting query strings. +- **Screenshots for the questions the page's structure cannot answer.** Visual layout, overlapping + or clipped elements, spacing, and canvas rendering. The capture is handed to the model as real + image input rather than text, and the model's ability to accept images is checked *before* + capturing — a text-only model is told plainly that visual layout could not be checked instead of + being handed bytes it would silently drop. Screenshots are also delivered during plan execution. +- **Development server previews.** The agent can exercise a running app instead of a static file. + Only HTTP or HTTPS on loopback with an explicit port is accepted; external hosts, LAN addresses, + other schemes, and URLs carrying credentials are refused. - **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. @@ -108,14 +127,28 @@ for every approved plan. Desktop's version follows the engine generation, so it use the same Microsoft.Extensions.AI client the engine standardized on. Same prompts, same temperatures, same behavior — but Desktop no longer depends on a framework the engine has removed. Snapshot recaps and note replies are the surfaces to sanity-check. -- **Engine safety pin: `7aede43`** (engine 0.15.0). This includes workflow planning as the default - 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. +- **Engine safety pin: `5aea416`** (engine 0.15.0). This includes workflow planning as the default + plan runner, manual conversation compaction, automatic planning based on task shape, the + large-root context guard verified through Desktop against a real `@directory` request, image + content counted toward the context estimate, and browser tab and frame listings always being + read live rather than answered from the recent-call cache. +- **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 appears once per session rather than on every model switch. +- **Screenshots stay honest when the window is not on screen.** Capture reads the window's + rendered surface, so it depends on the app having one. A minimized window is now reported as + needing to be restored, within a bounded wait, rather than consuming the whole operation + deadline and surfacing as a vague timeout. A capture that comes back nearly uniform is flagged + as possibly blank, so the model says the image looks empty instead of describing detail it + cannot see. Hidden, transparent, and occluded windows were measured and capture normally. - **A blocked click now names what is covering the target.** Instead of reporting only that an element is covered, the result identifies the element sitting on top of it — usually an overlay, a sticky header, or the suggestion list a field opens when it is filled. @@ -142,7 +175,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."> + + + + + + + + + + + + + + + +