Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<Compile Include="..\MandoCode.Desktop\Services\GitQuickStatus.cs" Link="src\GitQuickStatus.cs" />
<Compile Include="..\MandoCode.Desktop\Services\DesktopPreviewTools.cs" Link="src\DesktopPreviewTools.cs" />
<Compile Include="..\MandoCode.Desktop\Services\BrowserRequestContext.cs" Link="src\BrowserRequestContext.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ModelOrdering.cs" Link="src\ModelOrdering.cs" />
<Compile Include="..\MandoCode.Desktop\Services\DesktopPreviewScripts.cs" Link="src\DesktopPreviewScripts.cs" />
<Compile Include="..\MandoCode.Desktop\Services\DesktopPreviewKeys.cs" Link="src\DesktopPreviewKeys.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentImageSink.cs" Link="src\AgentImageSink.cs" />
Expand Down
108 changes: 108 additions & 0 deletions src/MandoCode.Desktop.Tests/ModelOrderingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>ModelOrdering holds process-wide state, so every test states its own starting point.</summary>
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);
}
}
8 changes: 7 additions & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> ConfirmDiscardPreviewChangesAsync()
{
Expand Down
2 changes: 1 addition & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<FontIcon Glyph="&#xE8A5;" FontSize="13" VerticalAlignment="Center"
<FontIcon x:Name="PreviewTitleIcon" Glyph="&#xE8A5;" FontSize="13" VerticalAlignment="Center"
Foreground="{StaticResource MandoAccentBrush}"/>
<TextBlock x:Name="PreviewTitleText" Grid.Column="1" FontWeight="SemiBold"
FontSize="13" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
Expand Down
54 changes: 50 additions & 4 deletions src/MandoCode.Desktop/MainWindow.Appearance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,62 @@ private async void TavilySave_Click(object sender, RoutedEventArgs e)
private async void RefreshModels_Click(object sender, RoutedEventArgs e) =>
await RefreshModelListAsync();

/// <summary>
/// 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.
/// </summary>
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<string> current) return;
if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text;
ModelCombo.ItemsSource = ModelOrdering.Arrange(current);
ApplyModelComboTarget();
}

/// <summary>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.</summary>
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<string> present || present.Count == 0))
{
_modelComboTarget = configured;
ModelCombo.ItemsSource = new List<string> { 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 <model>)";
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 <model>)"
: $"{models.Count} model(s) available.";
ModelListStatus.Text = $"{models.Count} model(s) available.";
}

private async void SettingsSave_Click(object sender, RoutedEventArgs e)
Expand Down
3 changes: 2 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Snapshots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion src/MandoCode.Desktop/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,30 @@
</Grid.ColumnDefinitions>
<ComboBox x:Name="ModelCombo" IsEditable="True" HorizontalAlignment="Stretch"
PlaceholderText="e.g. qwen2.5-coder:14b" MinWidth="512"
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."/>
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.">
<ComboBox.Resources>
<services:PinnedOpacityConverter x:Key="PinnedOpacity"/>
</ComboBox.Resources>
<ComboBox.ItemTemplate>
<DataTemplate>
<Grid ColumnSpacing="8" HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="1" Tag="{Binding}" Tapped="ModelPin_Tapped"
Background="Transparent" BorderThickness="0" Padding="4"
ToolTipService.ToolTip="Pin this model to the top of the list"
AutomationProperties.Name="Pin model to top">
<FontIcon Glyph="&#xE718;" FontSize="12"
Opacity="{Binding Converter={StaticResource PinnedOpacity}}"/>
</Button>
</Grid>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Click="RefreshModels_Click"
ToolTipService.ToolTip="Fetch pulled models">
<FontIcon Glyph="&#xE72C;" FontSize="14"/>
Expand Down
4 changes: 4 additions & 0 deletions src/MandoCode.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ public MainWindow()
// matters: a user who explicitly toggled numbering keeps it (saved false), while
// fresh installs and pre-0.14.1 panel states get callsigns.
AgentCallsigns.Enabled = panelState.AgentCallsigns ?? true;
// Model picker order is a window-level preference like the above. Persist on change so a
// pin or a model switch survives a crash, not just a clean exit.
ModelOrdering.Load(panelState.PinnedModels, panelState.RecentModels);
ModelOrdering.Changed += SavePanelState;
// The editor writes note content; the panel only lists. One store, handed over once.
NoteEditor.Store = _notes;
WireNotesPanel();
Expand Down
Loading
Loading