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 } };