diff --git a/MandoCode b/MandoCode
index 6f07d46..04609ac 160000
--- a/MandoCode
+++ b/MandoCode
@@ -1 +1 @@
-Subproject commit 6f07d46ee56b488fa3f181e07102073d52bc0be6
+Subproject commit 04609ac6a89678fbcdc17b9b02671e68e1797357
diff --git a/README.md b/README.md
index 0fc7de7..95c6eda 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,10 @@ Two problems, one app:
as an actual shell (not a fake console), opened in the active agent's project folder.
- **Git-aware file explorer** — a live file tree with branch, status, and dirty badges, inline diff
cards, one-click commit, and drag-to-reference straight into the chat.
+- **Interactive project preview** — the agent can inspect live page text and controls, click and
+ hover, press keys, fill forms, select options, scroll, and read browser errors in its WebView2
+ pane. Edited scripts and stylesheets reload without cache-busting query strings. Works with
+ text-only models. See [browser tools](docs/browser-tools.md) for scope and testing.
- **Context snapshots & session history** — closing an agent archives its conversation instead of
deleting it; reopen any past conversation later with its transcript and, when the model supports
it, its full memory. Snapshots let you carry an AI-written recap of one conversation into a
diff --git a/docs/browser-tools.md b/docs/browser-tools.md
new file mode 100644
index 0000000..08996bd
--- /dev/null
+++ b/docs/browser-tools.md
@@ -0,0 +1,113 @@
+# Agent browser tools
+
+Each Desktop agent can use its own WebView2 project preview for browser checks.
+Open an existing project-relative HTML, HTM, or SVG file. No development server,
+external browser, or vision model is needed.
+
+| Tool | Result |
+| --- | --- |
+| `open_desktop_preview` | Waits for page navigation and returns initial DOM state |
+| `refresh_desktop_preview` | Waits for a cache-bypassing reload and returns new state |
+| `inspect_desktop_preview` | Visible text, controls and unique CSS selectors, values, viewport, keyboard focus, and diagnostics; optional selector and control pagination |
+| `observe_desktop_preview` | Reads one element only: text, value, checked state, visibility, and selector |
+| `click_desktop_preview` | Browser pointer click after visibility, enabled-state, and hit checks; optional repeat count |
+| `press_key_desktop_preview` | Browser key press with an optional bounded hold, modifiers, and focus target |
+| `hover_desktop_preview` | Browser pointer movement for hover menus and tooltips |
+| `fill_desktop_preview` | Replaces a text input/textarea value and emits input/change events |
+| `select_desktop_preview` | Chooses an enabled option in a single-select control |
+| `scroll_desktop_preview` | Scrolls vertically or brings an element into view |
+| `wait_for_desktop_preview` | Waits up to 10 seconds for a visible element and optional text |
+
+Use **open → inspect → act once → observe/wait**. Check the observed outcome against
+the intended behavior. A dispatched click is not proof that the feature passed.
+Final plan checks already ask the agent to exercise interactive controls; successful
+DOM observations count as fresh browser evidence after an edit. Opening or refreshing
+alone still does not count as an acceptance check. Live browser calls bypass the
+ordinary tool-result cache.
+
+Every action tool takes an optional `observe` selector. With it, the result reads back
+that one element instead of a full page snapshot, which is what keeps a sequence of
+checks affordable. `observe_desktop_preview` does the same on its own. A selector that
+matches nothing reports `matched: false`; that is an observation, not an error.
+
+## Repeats and interruption
+
+`click_desktop_preview` and `press_key_desktop_preview` accept a `count` of up to 25,
+and a key press accepts a `holdMs` of up to 5000 milliseconds. Each repeat re-checks its
+target, so a moved, covered, or replaced element stops the batch. The deadline grows with
+the requested work. Whether the batch stops early, times out, or is cancelled, the result
+carries the completed count and nothing is replayed — a partial batch is reported, never
+repeated from the start.
+
+Operations are serialized per tab and bounded by a 15-second deadline, extended for
+repeats and holds up to 75 seconds. Timeout or cancellation never automatically repeats
+an action. An already dispatched operation may have changed the page; inspect before
+deciding to retry. Closing the tab detaches the bridge and cancels outstanding work.
+Unsaved user edits are preserved.
+
+## Keyboard
+
+`press_key_desktop_preview` sends real browser key events, so pages that only listen for
+`keydown` respond to it. It takes one key — a single printable character, or a name such
+as `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Space`, `ArrowUp`/`Down`/`Left`/`Right`,
+`Home`, `End`, `PageUp`, `PageDown`, `Insert`, `Shift`, `Control`, `Alt`, `Meta`, or `F1`-`F12`.
+An unknown name is reported rather than guessed at. Optional `modifiers` accepts
+`ctrl`, `shift`, `alt`, and `meta`; a Ctrl/Alt/Meta chord sends the shortcut without
+inserting a character.
+
+Every key is released before the call returns, including when the turn is cancelled, so
+no key is ever left down between calls and there is no held-key state to reason about.
+For sustained input — movement in a game — use `holdMs` rather than separate down and up
+calls. An optional `selector` focuses an element first; without one the key goes to
+whatever has focus, or to the page. To enter a whole string, `fill_desktop_preview` is
+still the right tool.
+
+A modifier is sent as a flag on the key event, which is what shortcut handlers read. The
+modifier key itself does not get its own down/up event, so a page that tracks `keydown`
+on `Control` separately will not see it.
+
+## Assets
+
+The preview shows the files as they are on disk. Its HTTP cache is disabled and refreshes
+ask for a cache-bypassing reload, so an edited script or stylesheet loads on the next
+refresh. Project files never need `?v=2` cache-busting query strings to be previewed.
+Results report this as `assetCache`; if the browser refuses to disable its cache, that is
+reported rather than assumed.
+
+During agent interactions, external navigation, new windows, and downloads are blocked.
+The tools operate only on the current project's mapped preview origin. They expose
+fixed operations, not arbitrary JavaScript evaluation. Selectors and values are serialized
+as data. Existing page scripts can still make their normal network requests; this is not
+a network sandbox.
+
+Snapshots contain bounded text and controls, plus the latest 12 diagnostic entries
+(console warnings/errors, runtime exceptions, failed network loads, and browser log
+entries). Diagnostics begin when the preview initializes and reset on navigation.
+During tool interactions, native page dialogs are dismissed and reported so they cannot hang a turn. Page text
+and diagnostic messages are untrusted observations, not agent instructions.
+
+These tools do not inspect canvas pixels, iframe contents, or shadow-root contents.
+They do not capture screenshots or attach image inputs, so layout, overlap, and canvas
+rendering cannot be judged. Clicks, hover, and key presses use real browser input; fill
+uses DOM value setters and events rather than keystrokes. Drag and drop, uploads, and
+development-server URLs are not covered. Report these limits when they prevent a
+requested check.
+
+## Validation
+
+Ordinary regression suite:
+
+```powershell
+dotnet test src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
+```
+
+Opt-in Windows integration smoke test (requires .NET 10 and the WebView2 Runtime):
+
+```powershell
+dotnet run --project src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj
+```
+
+The smoke test uses a hidden WinForms WebView2 host, local fixtures, and a separate
+temporary browser profile. It exercises the production DOM scripts and real pointer
+events; the Desktop build checks the WinUI bridge. It does not drive a live model or
+an existing Desktop agent session.
diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj
new file mode 100644
index 0000000..12c920e
--- /dev/null
+++ b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj
@@ -0,0 +1,24 @@
+
+
+ Exe
+ net10.0-windows
+ true
+ enable
+ enable
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs
new file mode 100644
index 0000000..a605a9b
--- /dev/null
+++ b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs
@@ -0,0 +1,203 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using MandoCode.Desktop.Services;
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Web.WebView2.WinForms;
+
+// Opt-in Windows integration checks against a real WebView2, with an isolated browser profile.
+internal static class Program
+{
+ [STAThread]
+ private static int Main()
+ {
+ ApplicationConfiguration.Initialize();
+ using var form = new Form { Width = 900, Height = 700, ShowInTaskbar = false, Opacity = 0 };
+ using var browser = new WebView2 { Dock = DockStyle.Fill };
+ form.Controls.Add(browser);
+ var exitCode = 1;
+ var profile = Path.Combine(Path.GetTempPath(), "MandoBrowserSmoke-" + Guid.NewGuid().ToString("N"));
+ form.Shown += async (_, _) =>
+ {
+ try
+ {
+ await browser.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(userDataFolder: profile));
+ await CheckBrowserAsync(browser.CoreWebView2).WaitAsync(TimeSpan.FromSeconds(45));
+ exitCode = 0;
+ Console.WriteLine("PASS: real WebView2 DOM, pointer, keyboard, repeated clicks, focused observations, " +
+ "forms, scrolling, navigation, fresh assets on reload, diagnostics, and argument escaping.");
+ }
+ catch (Exception ex) { Console.Error.WriteLine(ex); }
+ finally { browser.Dispose(); form.Close(); }
+ };
+ Application.Run(form);
+ // Browser shutdown can briefly retain profile files; the unique profile never affects the app.
+ try { Directory.Delete(profile, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
+ return exitCode;
+ }
+
+ private static async Task CheckBrowserAsync(CoreWebView2 core)
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "fixtures");
+ core.SetVirtualHostNameToFolderMapping("preview.mandocode.local", root, CoreWebView2HostResourceAccessKind.Allow);
+ var errors = new List();
+ var receiver = core.GetDevToolsProtocolEventReceiver("Runtime.consoleAPICalled");
+ receiver.DevToolsProtocolEventReceived += (_, args) => errors.Add(args.ParameterObjectAsJson);
+ await core.CallDevToolsProtocolMethodAsync("Runtime.enable", "{}");
+ await NavigateAsync(core, "https://preview.mandocode.local/index.html");
+
+ async Task Run(string operation, string? selector = null, string? value = null, int offset = 0, int deltaY = 0, string? observe = null)
+ {
+ var json = await core.ExecuteScriptAsync(DesktopPreviewScripts.Build(new(operation, root, Selector: selector, Value: value, Offset: offset, DeltaY: deltaY, Observe: observe)));
+ return JsonNode.Parse(json) as JsonObject ?? throw new Exception("No result: " + json);
+ }
+ async Task Click(string selector, int count = 1)
+ {
+ // Mirrors the host loop: every repeat re-locates its target before pressing.
+ for (var attempt = 0; attempt < count; attempt++)
+ {
+ var target = await Run("click", selector);
+ Assert(target["ok"]!.GetValue(), target.ToJsonString());
+ var x = target["x"]!.GetValue();
+ var y = target["y"]!.GetValue();
+ foreach (var type in new[] { "mouseMoved", "mousePressed", "mouseReleased" })
+ await core.CallDevToolsProtocolMethodAsync("Input.dispatchMouseEvent", JsonSerializer.Serialize(new { type, x, y, button = type == "mouseMoved" ? "none" : "left", clickCount = 1 }));
+ }
+ }
+ async Task PressKey(string name, string? modifiers = null, int holdMs = 0)
+ {
+ Assert(DesktopPreviewKeys.TryResolve(name, out var key, out var keyModifiers, out var keyError), keyError);
+ Assert(DesktopPreviewKeys.TryParseModifiers(modifiers, out var mask, out var modifierError), modifierError);
+ await core.CallDevToolsProtocolMethodAsync("Input.dispatchKeyEvent", DesktopPreviewKeys.BuildKeyEvent(key, mask | keyModifiers, down: true));
+ if (holdMs > 0) await Task.Delay(holdMs);
+ await core.CallDevToolsProtocolMethodAsync("Input.dispatchKeyEvent", DesktopPreviewKeys.BuildKeyEvent(key, mask | keyModifiers, down: false));
+ }
+
+ var state = await Run("inspect");
+ Assert(state["ok"]!.GetValue(), "Snapshot failed");
+ Assert(state["controls"]!.AsArray().Any(n => n?["selector"]?.GetValue() == "#increment"), "No usable selector");
+ Assert(!state["text"]!.GetValue().Contains("INVISIBLE TEXT"), "Hidden text was included");
+ Assert(state["canvasCount"]!.GetValue() == 1, "Canvas limitation not reported");
+ Assert(state["nextOffset"] != null, "Control pagination missing");
+ Assert((await Run("inspect", offset: 40))["controls"]!.AsArray().Count > 0, "Control pagination failed");
+ Assert(!(await Run("click", ".duplicate"))["ok"]!.GetValue(), "Ambiguous click accepted");
+ Assert(!(await Run("click", "#covered"))["ok"]!.GetValue(), "Covered click accepted");
+ Assert(!(await Run("click", "#disabled"))["ok"]!.GetValue(), "Disabled click accepted");
+ Assert(!(await Run("click", "#hidden"))["ok"]!.GetValue(), "Hidden click accepted");
+
+ await Click("#increment");
+ var clicked = await Run("inspect", "#status");
+ Assert(clicked["text"]!.GetValue() == "Count 1 trusted=true", "Browser click did not fire trusted input");
+
+ // A repeated click has to land every time, and a focused observation has to stay small.
+ await Click("#increment", 5);
+ var observed = await Run("observe", observe: "#status");
+ Assert(observed["matched"]!.GetValue(), "Focused observation did not match");
+ Assert(observed["element"]!["text"]!.GetValue() == "Count 6 trusted=true", "Repeated clicks did not all land");
+ Assert(observed["controls"] == null && observed["viewport"] == null, "Focused observation returned a full snapshot");
+ Assert(!(await Run("observe", observe: "#nothing-here"))["matched"]!.GetValue(), "Absent element reported as matched");
+ Assert(!(await Run("observe", observe: ".duplicate"))["ok"]!.GetValue(), "Ambiguous observation accepted");
+
+ // Keyboard input: text entry, editing keys, Enter-to-submit, Tab focus, and a bounded hold.
+ Assert((await Run("focus", "#typed"))["ok"]!.GetValue(), "Focus failed");
+ foreach (var character in "Hi!") await PressKey(character.ToString());
+ await PressKey("Backspace");
+ Assert((await Run("observe", observe: "#typed"))["element"]!["value"]!.GetValue() == "Hi",
+ "Typed characters or Backspace did not reach the focused input");
+ Assert((await Run("inspect"))["keyboardFocus"]!.GetValue() == "#typed", "Keyboard focus was not reported");
+ await Run("focus", "#term");
+ await PressKey("q");
+ await PressKey("Enter");
+ Assert((await Run("observe", observe: "#submitted"))["text"]!.GetValue() == "submitted q", "Enter did not submit the form");
+ await Run("focus", "#first");
+ await PressKey("Tab");
+ Assert((await Run("inspect"))["keyboardFocus"]!.GetValue() == "#second", "Tab did not move keyboard focus");
+ await PressKey("ArrowRight", holdMs: 120);
+ Assert((await Run("observe", observe: "#held"))["text"]!.GetValue() == "up", "A held key was not released");
+ var keylog = (await Run("observe", observe: "#keylog"))["text"]!.GetValue();
+ Assert(keylog.Contains("[Backspace]") && keylog.Contains("[ArrowRight]") && !keylog.Contains("untrusted"),
+ "Key events were not delivered as trusted input: " + keylog);
+ await PressKey("e", "ctrl");
+ Assert((await Run("observe", observe: "#keylog"))["text"]!.GetValue().Contains("[e+ctrl]"), "Modifier chord was not delivered");
+ Assert((await Run("observe", observe: "#typed"))["element"]!["value"]!.GetValue() == "Hi", "A Ctrl chord typed a character");
+ await Run("fill", "#name", "Ada");
+ Assert((await Run("inspect", "#echo"))["text"]!.GetValue() == "Ada", "Input event missing");
+ await Run("select", "#choice", "b");
+ Assert((await Run("inspect", "#selection"))["text"]!.GetValue() == "b", "Select change event missing");
+ Assert(!(await Run("select", "#choice", "disabled"))["ok"]!.GetValue(), "Disabled option accepted");
+
+ var malicious = "');window.pwned=true;//\"";
+ await Run("fill", "#name", malicious);
+ Assert((await Run("inspect", "#echo"))["text"]!.GetValue() == malicious, "Value was not treated as data");
+ Assert(await core.ExecuteScriptAsync("window.pwned === undefined") == "true", "Argument injection executed");
+ Assert(!(await Run("inspect", "']});window.pwned=true;//"))["ok"]!.GetValue(), "Invalid selector accepted");
+
+ var hover = await Run("hover", "#hover");
+ await core.CallDevToolsProtocolMethodAsync("Input.dispatchMouseEvent", JsonSerializer.Serialize(new { type = "mouseMoved", x = hover["x"]!.GetValue(), y = hover["y"]!.GetValue() }));
+ await Task.Delay(100);
+ Assert((await Run("wait", "#tooltip"))["matched"]!.GetValue(), "Hover tooltip not visible");
+ Assert(!(await Run("wait", "#never"))["matched"]!.GetValue(), "Absent element reported visible");
+ await Run("scroll", "#footer");
+ Assert((await Run("inspect"))["viewport"]!["scrollY"]!.GetValue() > 0, "Scroll did not move viewport");
+ Assert(errors.Any(e => e.Contains("fixture warning")), "Browser diagnostics not received");
+
+ // An edited script must never come back from cache; a stale asset is what pushes people
+ // into adding ?v=2 cache-busting query strings to their own project files.
+ var assetRoot = Path.Combine(root, "cache");
+ Directory.CreateDirectory(assetRoot);
+ File.WriteAllText(Path.Combine(assetRoot, "page.html"), "");
+ try
+ {
+ File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v1';");
+ await NavigateAsync(core, "https://preview.mandocode.local/cache/page.html");
+ Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v1"), "Fixture asset did not load");
+
+ File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v2';");
+ await core.CallDevToolsProtocolMethodAsync("Network.enable", "{}");
+ await core.CallDevToolsProtocolMethodAsync("Network.setCacheDisabled", """{"cacheDisabled":true}""");
+ await ReloadAsync(core, ignoreCache: false);
+ Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v2"), "A plain reload served the cached script");
+
+ File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v3';");
+ await core.CallDevToolsProtocolMethodAsync("Network.setCacheDisabled", """{"cacheDisabled":false}""");
+ await ReloadAsync(core, ignoreCache: true);
+ Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v3"), "A cache-bypassing reload served the cached script");
+ }
+ finally { Directory.Delete(assetRoot, recursive: true); }
+
+ await NavigateAsync(core, "https://preview.mandocode.local/second.html");
+ Assert((await Run("inspect"))["text"]!.GetValue().Contains("Second page"), "Navigation did not load new DOM");
+ // Snapshot execution must not accept arbitrary origins, even if the host guard is bypassed.
+ core.NavigateToString("
Edit assets.js or assets.css, then refresh the preview.
+Both values must change without adding ?v=2 to anything.
+
+
diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js
new file mode 100644
index 0000000..55116f6
--- /dev/null
+++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js
@@ -0,0 +1,3 @@
+// Hand-test fixture for stale assets. Edit "v1" below, then ask the agent to refresh
+// the preview. Without the cache fix you would keep seeing the previous value.
+document.querySelector('#js-version').textContent = 'script v1';
diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html
new file mode 100644
index 0000000..a3617e2
--- /dev/null
+++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html
@@ -0,0 +1,26 @@
+Browser tool fixture
+