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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ for every approved plan. Desktop's version follows the engine generation, so it
0.15.0.

### Added
- **PDFs open in the preview pane.** Selecting a PDF in the Explorer shows it in the browser's own
viewer — scroll, zoom, search, print — instead of only offering to open it in another
application. This is for reading: a PDF's text and structure are not reachable through the page
DOM, so the assistant cannot read one. It is told that plainly, and told to judge the document
from a screenshot on a vision-capable model, rather than being handed an empty page it might
report as a blank document.
- **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
Expand Down
64 changes: 64 additions & 0 deletions src/MandoCode.Desktop.Tests/PdfPreviewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Text.Json;
using MandoCode.Desktop.Services;
using MandoCode.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// PDFs are viewable by the user in the browser pane, but deliberately NOT openable by the agent:
/// a PDF's text and structure never reach the DOM, so an agent that opened one would be handed a
/// page that looks successfully loaded and reads as entirely empty. These pin that asymmetry.
/// </summary>
public sealed class PdfPreviewTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "MandoPdfPreview-" + Guid.NewGuid().ToString("N"));
public PdfPreviewTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private DesktopPreviewTools Tools() => new(new ProjectRootAccessor(_root)) { RequireTabId = true };
private static bool Ok(string json) => JsonDocument.Parse(json).RootElement.GetProperty("ok").GetBoolean();

[Fact]
public async Task TheAgentCannotOpenAPdfAndIsToldWhichTypesWork()
{
File.WriteAllText(Path.Combine(_root, "report.pdf"), "%PDF-1.4 not really a pdf");
var tools = Tools();
var dispatched = 0;
tools.ExecuteAsync = (_, _) => { dispatched++; return Task.FromResult("{\"ok\":true}"); };

var result = await tools.OpenDesktopPreview("report.pdf");

Assert.False(Ok(result));
Assert.Contains(".html", result);
Assert.Equal(0, dispatched); // refused before anything reached the browser
}

[Fact]
public async Task TheSameCallStillWorksForAPageTheAgentCanActuallyRead()
{
File.WriteAllText(Path.Combine(_root, "index.html"), "<!doctype html><p>hi");
var tools = Tools();
string? opened = null;
tools.ExecuteAsync = (request, _) => { opened = request.FullPath; return Task.FromResult("{\"ok\":true}"); };

await tools.OpenDesktopPreview("index.html");

Assert.NotNull(opened);
Assert.EndsWith("index.html", opened);
}

[Fact]
public void InspectionReportsAPdfRatherThanReturningAnEmptyPage()
{
var script = DesktopPreviewScripts.Build(
new("inspect", _root, Origin: "https://preview.mandocode.local"));

// The guard must run for DOM reads, name the viewer as the reason, and say plainly that an
// empty result is not evidence of an empty document.
Assert.Contains("application/pdf", script);
Assert.Contains("not evidence that the document is empty", script);
// Screenshots are the supported way to judge a PDF, so their support operations stay exempt.
Assert.Contains("args.operation !== 'pagestate'", script);
Assert.Contains("screenshot_desktop_preview", script);
}
}
9 changes: 8 additions & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,16 @@ await Task.Run(async () =>
{
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico"
};
/// <summary>
/// Rendered in the browser pane rather than as text or an image. PDFs are here for the user's
/// benefit only — the browser's own viewer displays them. The agent's allowlist
/// (DesktopPreviewTools.BrowserExtensions) deliberately does NOT include .pdf: a PDF's contents
/// are not reachable through the DOM, so letting the agent open one would hand it a page that
/// looks successfully loaded and reads as completely empty.
/// </summary>
private static readonly HashSet<string> BrowserPreviewExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".svg"
".html", ".htm", ".svg", ".pdf"
};
private const long MaxPreviewBytes = 1024 * 1024;

Expand Down
7 changes: 7 additions & 0 deletions src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ public static string Build(DesktopPreviewRequest request) => "(() => { const arg
const result = () => args.observe ? observation(args.observe) : snapshot();
try {
if (!args.origin || location.origin !== args.origin) throw new Error('This is not the preview origin the host opened.');
// A PDF is drawn by the browser viewer, and none of its text, pages, or fields reach the
// DOM. Returning an empty snapshot would read as "the document is blank" — the same
// wrong conclusion an uninspected frame used to produce. Screenshot support operations
// are exempt, because an image is precisely how a PDF should be judged.
if (args.operation !== 'pagestate' && args.operation !== 'bounds' &&
(document.contentType === 'application/pdf' || document.querySelector('embed[type="application/pdf"]')))
return { ok: false, isPdf: true, error: 'This document is a PDF drawn by the browser PDF viewer. Its text, pages, and form fields are not reachable through the DOM, so an empty result here is not evidence that the document is empty. Judge it with screenshot_desktop_preview on a vision-capable model, or read the file from disk instead.' };
if (args.operation === 'inspect' || args.operation === 'observe') return result();
if (args.operation === 'pagestate') return { ok: true, url: location.href, title: cut(document.title, 200),
readyState: document.readyState, viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY } };
Expand Down
Loading