From 6954c9a98437de54a31eb870374709a62d80a9a5 Mon Sep 17 00:00:00 2001 From: DevMando Date: Mon, 7 Sep 2026 17:34:18 -0700 Subject: [PATCH] Open PDFs in the preview pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDFs previously fell through to "preview is available for code, text, configuration, and common image files" and could only be opened in an external application. WebView2 carries Edge's PDF viewer, so rendering one in the docked pane costs a single entry in the Explorer's browser-preview set. The agent's allowlist deliberately does NOT gain .pdf. A PDF's text, pages, and fields are drawn by that viewer and never reach the DOM, so letting the agent open one would hand it a page that looks successfully loaded and inspects as completely empty — the same false "there is nothing here" conclusion an uninspected frame used to produce. For the case where the user opens a PDF and then asks about it, DOM operations now report what the document is instead of returning an empty snapshot, and point at a screenshot on a vision-capable model. Screenshot support operations stay exempt, since an image is precisely how a PDF should be judged. --- CHANGELOG.md | 6 ++ .../PdfPreviewTests.cs | 64 +++++++++++++++++++ .../Controls/ChatTabView.Explorer.cs | 9 ++- .../Services/DesktopPreviewScripts.cs | 7 ++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/MandoCode.Desktop.Tests/PdfPreviewTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 636c703..00c8c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/MandoCode.Desktop.Tests/PdfPreviewTests.cs b/src/MandoCode.Desktop.Tests/PdfPreviewTests.cs new file mode 100644 index 0000000..10f450a --- /dev/null +++ b/src/MandoCode.Desktop.Tests/PdfPreviewTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using MandoCode.Desktop.Services; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// 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. +/// +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"), "

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); + } +} diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs index f3218dd..a76e313 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs @@ -311,9 +311,16 @@ await Task.Run(async () => { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico" }; + ///

+ /// 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. + /// private static readonly HashSet BrowserPreviewExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".html", ".htm", ".svg" + ".html", ".htm", ".svg", ".pdf" }; private const long MaxPreviewBytes = 1024 * 1024; diff --git a/src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs b/src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs index 3980b53..48b0d2b 100644 --- a/src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs +++ b/src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs @@ -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 } };