From e51f59463508a99b892bc04b0b51eee01ee7d505 Mon Sep 17 00:00:00 2001 From: DevMando Date: Mon, 7 Sep 2026 15:34:31 -0700 Subject: [PATCH] Add a shared tabbed browser with embedded form support The preview pane becomes a real browser: multiple tabs, an address bar, back/forward/reload, and a toolbar button that opens it independently of the agent. User websites and agent project previews live side by side in one pane. Agent actions require an explicit tab ID. The tab the user was viewing when they sent a message is captured at submit time, so "this page" keeps meaning that tab even after the UI selection changes, and a closed target reports failure instead of redirecting the agent to a different tab. Browser tools also discover cross-origin and nested frames and can inspect, fill, select, scroll, and wait inside them, so embedded forms are no longer invisible. Also in this branch: - Frame identities survive a navigation that was blocked and never happened. - Host browser bookkeeping stays out of plan cards, out of the plan manifest that persists in chat history, and out of the model's replies. - A blocked click reports which element is covering the target. - Failures raised mid-operation carry their tab and console output. - Startup announces one model rather than two, with image capability on the same line as the model it describes. Pins the engine to the matching tool-cache fix. --- .gitignore | 1 + CHANGELOG.md | 30 +- MandoCode | 2 +- docs/browser-tools.md | 52 +++- .../BrowserSmokeTests.csproj | 1 + .../Program.cs | 106 ++++++- .../fixtures/frame-contact.html | 7 + .../fixtures/frame-host.html | 4 + .../fixtures/frame-nested.html | 1 + .../BrowserTabTargetingTests.cs | 173 +++++++++++ .../MandoCode.Desktop.Tests.csproj | 1 + src/MandoCode.Desktop.Tests/PlanCardTests.cs | 25 ++ .../Controls/ChatTabView.BrowserTabs.cs | 205 +++++++++++++ .../Controls/ChatTabView.BrowserTools.cs | 278 ++++++++++-------- .../Controls/ChatTabView.Explorer.cs | 119 +------- .../Controls/ChatTabView.Input.cs | 3 +- .../Controls/ChatTabView.xaml | 22 +- .../Controls/ChatTabView.xaml.cs | 2 +- src/MandoCode.Desktop/MainWindow.Terminal.cs | 16 +- .../Services/AgentSession.cs | 5 +- .../Services/BrowserFrames.cs | 95 ++++++ .../Services/BrowserRequestContext.cs | 51 ++++ .../Services/DesktopPreviewScripts.cs | 12 +- .../Services/DesktopPreviewTools.cs | 93 +++--- .../Services/PlanCardHtml.cs | 2 +- .../ViewModels/ChatController.Plans.cs | 16 +- .../ViewModels/ChatController.cs | 80 ++++- 27 files changed, 1090 insertions(+), 312 deletions(-) create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-contact.html create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-host.html create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-nested.html create mode 100644 src/MandoCode.Desktop.Tests/BrowserTabTargetingTests.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.BrowserTabs.cs create mode 100644 src/MandoCode.Desktop/Services/BrowserFrames.cs create mode 100644 src/MandoCode.Desktop/Services/BrowserRequestContext.cs diff --git a/.gitignore b/.gitignore index 0808c4a..95dd574 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ mono_crash.* # Build results +.validation/ [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0698250..b30f647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **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. + Inspection distinguishes uninspected frame content from missing form fields. +- **Shared browser tabs.** The browser button beside Snapshot opens the existing pane + with tabs, a new-tab button, an address bar, and back/forward/reload controls. User + websites and agent previews coexist. Agent actions require explicit tab IDs; references + to “this page” retain the tab viewed when the message was sent, even after switching tabs. + Closing a targeted tab reports failure rather than redirecting the agent to another tab. - **Docked file previews from the Explorer.** Selecting a file opens a resizable, read-only preview between the chat and file tree. Code, text, configuration, documentation, and common image formats open in Desktop; unsupported or large files offer the existing external-open @@ -103,7 +112,19 @@ for every approved plan. Desktop's version follows the engine generation, so it 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. +- **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. +- **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. + ### Fixed +- **A restored tab no longer announces two different models at startup.** Restoring a session + announced the default model, then immediately switched to the tab's saved model and announced + that one as well. The first notice was obsolete the moment it appeared, and could advertise + image support on a model that was never used. - **The token total now reflects what the provider actually processed.** Desktop no longer adds rough character-based estimates for reads, searches, web results, writes, or attachments on top of the provider's prompt and completion counts. File reads still show their line counts. @@ -115,11 +136,16 @@ 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 -239 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction +295 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. The same workflows were also exercised with real models, including +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 closing the process between steps and resuming from the saved cursor. +An opt-in smoke test drives a real WebView2 browser end to end: DOM reads, pointer and keyboard +input, cross-origin and nested frame discovery, filling and reading back embedded form fields +without submitting, background-tab isolation, and rejection of stale or removed frame targets. + ## [0.14.1] — 2026-07-28 First-five-minutes polish from watching 0.14.0's fresh-machine debut, plus honest guidance diff --git a/MandoCode b/MandoCode index 6c90c13..5aea416 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 6c90c130241e2725b411f2865936c8bca8da6b5e +Subproject commit 5aea416bbb853e675c8a01163a31cfd2d94b86ac diff --git a/docs/browser-tools.md b/docs/browser-tools.md index 1299291..3527136 100644 --- a/docs/browser-tools.md +++ b/docs/browser-tools.md @@ -1,11 +1,24 @@ # 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, or a development server -already running on loopback. DOM checks need no vision model; screenshots do. +Each Desktop conversation has a shared, tabbed WebView2 browser. The browser button +beside Snapshot opens it independently of the agent. Users can add and close tabs, +enter HTTP(S) URLs, and go back, forward, or reload. Project previews and development +servers use the same pane. DOM checks need no vision model; screenshots do. + +Every operation on an existing tab requires an explicit `tabId`. Opening without an +ID creates a new tab; opening with an ID navigates only that tab. Use the ID returned +by an open operation or `list_browser_tabs`. Tab selection never routes agent actions. +When the user sends a message, Desktop captures the viewed tab's identity before +background processing starts. “This page” means that captured tab even after a UI +switch. A closed or unknown target fails; no other tab is substituted. Browser context +also travels with plan instructions for retry and resume. Tabs themselves are session-only: +after restarting Desktop, old tab IDs are unavailable and must be explicitly re-established. | Tool | Result | | --- | --- | +| `list_browser_tabs` | Stable tab IDs, titles, URLs, and current selection; always read live | +| `list_browser_frames` | Embedded and nested frame document IDs, parent IDs, URLs and navigation state within an explicit tab | +| `open_browser_tab` | Opens an HTTP(S) URL in a new tab, or navigates an explicit existing tab | | `open_desktop_preview` | Waits for page navigation and returns initial DOM state | | `open_local_server_desktop_preview` | Same, for a development server on localhost or 127.0.0.1 | | `refresh_desktop_preview` | Waits for a cache-bypassing reload and returns new state | @@ -34,6 +47,25 @@ matches nothing reports `matched: false`; that is an observation, not an error. ## Repeats and interruption +### Embedded forms + +DOM tools accept an optional `frameId` alongside the required `tabId`. Omit it (or use +`main`) for the top-level document. `inspect`, `observe`, `fill`, `select`, `scroll`, and +`wait` operate inside the selected frame through WebView2's frame API, including +cross-origin frames. Pointer, keyboard, and screenshot tools currently reject child-frame +targets instead of incorrectly acting on the parent document. + +Top-level inspections list available frame identities and disclose uninspected frames. +Zero parent controls does not establish that an embedded form is absent. Selecting an +iframe element reports that its fallback text is not its document. Discover the frame, +inspect its fields, fill authorized values, and use observe to read them back. Fill/select +results also include expected and actual values. They do not submit the form, but normal +page input/change handlers still run. + +Frame IDs belong to one tab and document lifetime. Navigation, replacement, and removal +invalidate old IDs; tools fail without substituting another document. Loading or failed +frame access is reported as unavailable rather than as an empty form. + `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 @@ -41,7 +73,7 @@ the requested work. Whether the batch stops early, times out, or is cancelled, t 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 +Agent operations are serialized per conversation 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. @@ -76,9 +108,10 @@ refresh. Project files never need `?v=2` cache-busting query strings to be previ 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 single origin the preview was opened on — the project's -mapped virtual host, or one loopback development server. They expose +Project preview tabs restrict navigation to their project origin or loopback server. +General browser tabs allow HTTP(S) navigation, including redirects. Agent-triggered +new windows and downloads remain blocked; user-initiated new-window links open another +browser tab. The tools 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. @@ -89,7 +122,7 @@ 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. -DOM inspection does not reach canvas pixels, iframe contents, or shadow-root contents; +DOM inspection does not reach canvas pixels or shadow-root contents; a screenshot is the way to judge those, and only with a vision-capable model. Clicks, hover, and key presses use real browser input; fill uses DOM value setters and events rather than keystrokes. Drag and drop and file uploads are not covered. Report these @@ -101,6 +134,9 @@ limits when they prevent a requested check. given a selector, and hands the image to the model as real image input. Use it only for what the DOM cannot answer: layout, overlapping or clipped elements, spacing, and canvas rendering. Text, values, and control state are far cheaper to read with inspect or observe. +The targeted browser tab must be selected and its pane visible for screenshot capture. +Background tabs remain available for DOM operations; screenshot requests never switch +the user's selected tab automatically. It requires a model that accepts image input. Capability is checked *before* capturing, so a text-only model is told plainly that visual layout could not be checked rather than being diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj index 12c920e..d00ca4b 100644 --- a/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj +++ b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs index 9d5de2b..56f80d9 100644 --- a/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs +++ b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs @@ -22,9 +22,10 @@ private static int Main() { await browser.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(userDataFolder: profile)); await CheckBrowserAsync(browser.CoreWebView2).WaitAsync(TimeSpan.FromSeconds(45)); + await CheckTwoTabsAsync(form, browser).WaitAsync(TimeSpan.FromSeconds(20)); exitCode = 0; Console.WriteLine("PASS: real WebView2 DOM, pointer, keyboard, repeated clicks, focused observations, " + - "forms, scrolling, navigation, fresh assets on reload, screenshots under changing window visibility, origin scoping, diagnostics, and argument escaping."); + "forms, scrolling, navigation, fresh assets on reload, screenshots under changing window visibility, origin scoping, diagnostics, argument escaping, and independent background-tab DOM operations."); } catch (Exception ex) { Console.Error.WriteLine(ex); } finally { browser.Dispose(); form.Close(); } @@ -35,6 +36,93 @@ private static int Main() return exitCode; } + private static async Task CheckTwoTabsAsync(Form form, WebView2 first) + { + using var second = new WebView2 { Dock = DockStyle.Fill }; + form.Controls.Add(second); + await second.EnsureCoreWebView2Async(first.CoreWebView2.Environment); + var root = Path.Combine(AppContext.BaseDirectory, "fixtures"); + const string origin = "https://preview.mandocode.local"; + second.CoreWebView2.SetVirtualHostNameToFolderMapping("preview.mandocode.local", root, CoreWebView2HostResourceAccessKind.DenyCors); + await NavigateAsync(first.CoreWebView2, origin + "/index.html"); + await NavigateAsync(second.CoreWebView2, origin + "/index.html"); + // Match Desktop: changing visible controls leaves the captured WebView target intact. + var capturedTarget = first.CoreWebView2; + first.Visible = false; + second.Visible = true; + second.BringToFront(); + var result = JsonNode.Parse(await capturedTarget.ExecuteScriptAsync(DesktopPreviewScripts.Build( + new("fill", root, Selector: "#name", Value: "only the captured tab", Origin: origin))))!; + Assert(result["ok"]!.GetValue(), "Background target could not perform its DOM operation"); + Assert((await capturedTarget.ExecuteScriptAsync("document.querySelector('#name').value")).Contains("only the captured tab"), "Captured target did not change"); + Assert(await second.CoreWebView2.ExecuteScriptAsync("document.querySelector('#name').value") == "\"\"", "Selected tab was changed by another tab's action"); + await CheckFramesAsync(first.CoreWebView2, second.CoreWebView2, root); + second.Dispose(); + first.Visible = true; + } + + private static async Task CheckFramesAsync(CoreWebView2 target, CoreWebView2 selected, string root) + { + var frames = new BrowserFrames(target); + var otherFrames = new BrowserFrames(selected); + foreach (var core in new[] { target, selected }) + core.SetVirtualHostNameToFolderMapping("forms.mandocode.local", root, CoreWebView2HostResourceAccessKind.DenyCors); + const string origin = "https://preview.mandocode.local"; + await NavigateAsync(target, origin + "/frame-host.html"); + await NavigateAsync(selected, origin + "/frame-host.html"); + for (var attempt = 0; attempt < 100; attempt++) + { + if (frames.Describe().Count == 2 && otherFrames.Describe().Count == 2 && + frames.Describe().All(n => n?["state"]?.GetValue() == "ready") && + otherFrames.Describe().All(n => n?["state"]?.GetValue() == "ready")) break; + await Task.Delay(50); + } + var parent = JsonNode.Parse(await target.ExecuteScriptAsync(DesktopPreviewScripts.Build(new("inspect", root, Origin: origin))))!; + Assert(parent["uninspectedFrames"]!.GetValue() == 1, "Parent did not disclose uninspected frames"); + var frameElement = JsonNode.Parse(await target.ExecuteScriptAsync(DesktopPreviewScripts.Build(new("inspect", root, Selector: "#contact", Origin: origin))))!; + Assert(frameElement["ok"]!.GetValue() == false, "Iframe fallback text was misrepresented as frame document inspection"); + string ContactId(BrowserFrames registry) => registry.Describe().First(n => n?["url"]?.GetValue()?.EndsWith("/frame-contact.html") == true)!["frameId"]!.GetValue(); + var frameId = ContactId(frames); + var inspect = await frames.ExecuteAsync(new("inspect", root, FrameId: frameId), CancellationToken.None); + Assert(inspect["controls"]!.AsArray().Any(n => n?["selector"]?.GetValue() == "#first"), "Cross-origin frame fields unavailable"); + foreach (var field in new[] { ("#first", "Alex"), ("#last", "Example") }) + { + var filled = await frames.ExecuteAsync(new("fill", root, Selector: field.Item1, Value: field.Item2, FrameId: frameId), CancellationToken.None); + Assert(filled["fieldVerification"]?["matches"]?.GetValue() == true, "Field did not retain placeholder value"); + var observed = await frames.ExecuteAsync(new("observe", root, Observe: field.Item1, FrameId: frameId), CancellationToken.None); + Assert(observed["element"]?["value"]?.GetValue() == field.Item2, "Read-back did not confirm value"); + } + var submissions = await frames.ExecuteAsync(new("observe", root, Observe: "#submissions", FrameId: frameId), CancellationToken.None); + Assert(submissions["text"]?.GetValue() == "0", "Filling submitted the form"); + var untouched = await otherFrames.ExecuteAsync(new("observe", root, Observe: "#first", FrameId: ContactId(otherFrames)), CancellationToken.None); + Assert(untouched["element"]?["value"]?.GetValue() == "", "Selected tab was modified instead of the target"); + Assert(frames.Describe().Any(n => n?["parentFrameId"]?.GetValue() == frameId), "Nested frame was not discovered"); + try + { + await otherFrames.ExecuteAsync(new("fill", root, Selector: "#first", Value: "wrong", FrameId: frameId), CancellationToken.None); + throw new Exception("A frame ID from another tab was accepted"); + } + catch (InvalidOperationException) { } + await target.ExecuteScriptAsync("document.querySelector('#contact').src='https://forms.mandocode.local/frame-contact.html?new=1'"); + for (var attempt = 0; attempt < 100 && frames.Describe().Any(n => n?["frameId"]?.GetValue() == frameId); attempt++) await Task.Delay(20); + try + { + await frames.ExecuteAsync(new("fill", root, Selector: "#first", Value: "wrong", FrameId: frameId), CancellationToken.None); + throw new Exception("Navigated frame accepted a stale action"); + } + catch (InvalidOperationException) { } + for (var attempt = 0; attempt < 100 && !frames.Describe().Any(n => n?["state"]?.GetValue() == "ready" && n?["url"]?.GetValue()?.Contains("?new=1") == true); attempt++) await Task.Delay(20); + var replacementId = frames.Describe().First(n => n?["url"]?.GetValue()?.Contains("?new=1") == true)!["frameId"]!.GetValue(); + await target.ExecuteScriptAsync("document.querySelector('#contact').remove()"); + try + { + await frames.ExecuteAsync(new("fill", root, Selector: "#first", Value: "wrong", FrameId: replacementId), CancellationToken.None); + throw new Exception("Removed frame accepted a stale action"); + } + catch (InvalidOperationException) { } + Console.WriteLine("PASS: cross-origin and nested frame discovery, placeholder fill/read-back, no submission, background-tab isolation, and removed-frame rejection."); + } + private static async Task CheckBrowserAsync(CoreWebView2 core) { var root = Path.Combine(AppContext.BaseDirectory, "fixtures"); @@ -107,7 +195,9 @@ async Task PressKey(string name, string? modifiers = null, int holdMs = 0) 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"); + var covered = await Run("click", "#covered"); + Assert(!covered["ok"]!.GetValue(), "Covered click accepted"); + Assert(covered["error"]!.GetValue().Contains("#cover"), "Covered click did not name what covers the target"); Assert(!(await Run("click", "#disabled"))["ok"]!.GetValue(), "Disabled click accepted"); Assert(!(await Run("click", "#hidden"))["ok"]!.GetValue(), "Hidden click accepted"); @@ -183,15 +273,16 @@ async Task PressKey(string name, string? modifiers = null, int holdMs = 0) Assert(clipped.Length < full.Length, $"Clipping captured no less than the full page ({clipped.Length} vs {full.Length})"); Assert(!(await Run("bounds", "#hidden"))["ok"]!.GetValue(), "A hidden element was accepted for capture"); - // Capture depends on the window having a compositor surface. A minimized window has none, - // and the browser never answers at all, so the production path bounds this rather than - // letting one call eat the whole operation deadline. + // Capture while minimized varies with runtime/compositor state. The contract is a valid + // image or a bounded timeout, not that a particular runtime must hang. var host = Application.OpenForms[0]!; async Task<(string Outcome, int Bytes)> TimedCapture() { try { var bytes = await CaptureFrom(true).WaitAsync(TimeSpan.FromSeconds(4)); + Assert(bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47, + "Capture returned invalid image bytes"); return ("captured", bytes.Length); } catch (TimeoutException) { return ("hung", 0); } @@ -205,9 +296,8 @@ async Task PressKey(string name, string? modifiers = null, int holdMs = 0) host.WindowState = FormWindowState.Minimized; await Task.Delay(500); var minimized = await TimedCapture(); - Assert(minimized.Outcome == "hung", - $"Minimized capture no longer hangs ({minimized.Outcome}, {minimized.Bytes} bytes) — the production " + - "deadline and its guidance message may now be unnecessary; re-check before removing them."); + Assert(minimized.Outcome == "hung" || minimized.Outcome == "captured" && minimized.Bytes >= 8, + "Minimized capture neither returned a valid image nor reached the bounded timeout."); host.WindowState = FormWindowState.Normal; host.Opacity = 0; await Task.Delay(500); diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-contact.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-contact.html new file mode 100644 index 0000000..ce96976 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-contact.html @@ -0,0 +1,7 @@ +Contact details +
+
+

0

+ + + diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-host.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-host.html new file mode 100644 index 0000000..dc40ee9 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-host.html @@ -0,0 +1,4 @@ +Embedded contact form +

Contact form example

Fill in the embedded form.
+ + diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-nested.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-nested.html new file mode 100644 index 0000000..7d12d6a --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/frame-nested.html @@ -0,0 +1 @@ + diff --git a/src/MandoCode.Desktop.Tests/BrowserTabTargetingTests.cs b/src/MandoCode.Desktop.Tests/BrowserTabTargetingTests.cs new file mode 100644 index 0000000..cffa12d --- /dev/null +++ b/src/MandoCode.Desktop.Tests/BrowserTabTargetingTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using MandoCode.Desktop.Services; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class BrowserTabTargetingTests +{ + [Fact] + public async Task FrameIdentityTravelsWithTheTabAndField() + { + var tools = Tools(); + tools.ExecuteAsync = (request, _) => + { + Assert.Equal("tab-a", request.TabId); + Assert.Equal("frame-a", request.FrameId); + Assert.Equal("#first", request.Selector); + Assert.Equal("Alex", request.Value); + return Task.FromResult("{\"ok\":true}"); + }; + await tools.FillDesktopPreview("#first", "Alex", tabId: "tab-a", frameId: "frame-a"); + } + + [Fact] + public async Task UnsupportedFramePointerActionFailsBeforeDispatch() + { + var tools = Tools(); + tools.ExecuteAsync = (_, _) => throw new Exception("Must not click the parent page"); + var result = await tools.ClickDesktopPreview("#submit", tabId: "tab-a", frameId: "frame-a"); + Assert.Contains("does not support frame targeting", result); + Assert.DoesNotContain("Must not click", result); + } + + private static DesktopPreviewTools Tools() => new(new ProjectRootAccessor(Path.GetTempPath())) { RequireTabId = true }; + + [Fact] + public async Task MissingTargetNeverDispatchesAnAction() + { + var tools = Tools(); + tools.ExecuteAsync = (_, _) => throw new Exception("An implicit target was dispatched"); + foreach (var result in new[] { + await tools.InspectDesktopPreview(), await tools.RefreshDesktopPreview(), + await tools.ClickDesktopPreview("#save"), await tools.FillDesktopPreview("#name", "hello") }) + { + Assert.Contains("explicit tabId", result); + Assert.DoesNotContain("implicit target", result); + } + } + + [Fact] + public async Task QueuedActionRetainsItsExplicitTabAfterSelectionChanges() + { + var tools = Tools(); + var selected = "tab-a"; + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var targets = new List(); + tools.ExecuteAsync = (request, _) => { + targets.Add(request.TabId); + return targets.Count == 1 ? gate.Task : Task.FromResult("{\"ok\":true}"); + }; + var first = tools.InspectDesktopPreview(tabId: selected); + var second = tools.ClickDesktopPreview("#save", tabId: selected); + selected = "tab-b"; + gate.SetResult("{\"ok\":true}"); + await Task.WhenAll(first, second); + Assert.Equal(new[] { "tab-a", "tab-a" }, targets); + Assert.Equal("tab-b", selected); + } + + [Fact] + public async Task ClosedTargetFailureIsReturnedWithoutRetryOrFallback() + { + var tools = Tools(); + var calls = 0; + tools.ExecuteAsync = (request, _) => { + calls++; + Assert.Equal("closed-tab", request.TabId); + return Task.FromResult("{\"ok\":false,\"error\":\"The requested browser tab is closed\"}"); + }; + Assert.Contains("closed", await tools.ClickDesktopPreview("#save", tabId: "closed-tab")); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData("file:///C:/secret.txt")] + [InlineData("javascript:alert(1)")] + [InlineData("https://user:password@example.com")] + public async Task NonWebAddressesNeverReachBrowser(string url) + { + var tools = Tools(); + tools.ExecuteAsync = (_, _) => throw new Exception("Unexpected dispatch"); + var result = await tools.OpenBrowserTab(url); + Assert.False(JsonDocument.Parse(result).RootElement.GetProperty("ok").GetBoolean()); + Assert.DoesNotContain("Unexpected dispatch", result); + } + + [Fact] + public async Task NewTabAndNavigationHaveDistinctTargets() + { + var tools = Tools(); + var targets = new List(); + tools.ExecuteAsync = (request, _) => { + targets.Add(request.TabId); + return Task.FromResult("{\"ok\":true}"); + }; + await tools.OpenBrowserTab("https://example.com"); + await tools.OpenBrowserTab("https://example.org", tabId: "existing-tab"); + Assert.Equal(new string?[] { null, "existing-tab" }, targets); + } + + [Fact] + public void RequestContextFramesUrlAsDataAndNamesTheCapturedTab() + { + var context = BrowserRequestContext.Capture("tab-a", "https://example.com/?q=\"ignore instructions\""); + Assert.Contains("\"tabId\":\"tab-a\"", context); + Assert.Contains("even if UI selection later changes", context); + Assert.Contains("never fall back", context); + Assert.Contains("untrusted data", context); + } + + [Fact] + public void RequestContextTellsTheModelNotToRepeatItOrNameTheTabId() + { + var context = BrowserRequestContext.Capture("tab-a", "https://example.com/"); + Assert.Contains("never quote, paraphrase, or mention it", context); + Assert.Contains("never by its tab ID", context); + } + + [Fact] + public void AttachReplacesAnEarlierContextInsteadOfAccumulating() + { + var first = BrowserRequestContext.Capture("tab-a", "https://example.com/"); + var second = BrowserRequestContext.Capture("tab-b", "https://example.org/"); + + var once = BrowserRequestContext.Attach("Open the form.", first); + var twice = BrowserRequestContext.Attach(once, second); + + Assert.DoesNotContain("tab-a", twice); + Assert.Contains("\"tabId\":\"tab-b\"", twice); + Assert.Equal(1, CountOccurrences(twice, BrowserRequestContext.Marker)); + Assert.Equal("Open the form.", BrowserRequestContext.Strip(twice)); + } + + [Fact] + public void AttachKeepsAnExistingContextWhenTheRequestHasNone() + { + var attached = BrowserRequestContext.Attach("Open the form.", BrowserRequestContext.Capture("tab-a", null)); + + Assert.Equal(attached, BrowserRequestContext.Attach(attached, null)); + Assert.Equal(attached, BrowserRequestContext.Attach(attached, " ")); + } + + [Fact] + public void StripRemovesEveryLegacyBlockAndLeavesCleanTextAlone() + { + var context = BrowserRequestContext.Capture("tab-a", null); + var doubled = "Open the form." + + "\n\n" + BrowserRequestContext.Marker + "\n" + context + + "\n\n" + BrowserRequestContext.Marker + "\n" + context; + + Assert.Equal("Open the form.", BrowserRequestContext.Strip(doubled)); + Assert.Equal("Open the form.", BrowserRequestContext.Strip("Open the form.")); + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) count++; + return count; + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 2319dd0..4e7c6e0 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/src/MandoCode.Desktop.Tests/PlanCardTests.cs b/src/MandoCode.Desktop.Tests/PlanCardTests.cs index 573b568..af84474 100644 --- a/src/MandoCode.Desktop.Tests/PlanCardTests.cs +++ b/src/MandoCode.Desktop.Tests/PlanCardTests.cs @@ -28,4 +28,29 @@ public void PlanCard_ShowsExecutableInstructionsAndEscapesThem() Assert.Contains("Edit <ApiClient.cs> & run focused tests.", html); Assert.DoesNotContain("Edit ", html); } + + [Fact] + public void PlanCard_HidesHostBrowserContextFromTheReviewer() + { + var context = BrowserRequestContext.Capture("924730e0888f4721a78eecfe166bd402", "https://example.com/search"); + var plan = new TaskPlan + { + Steps = + [ + new TaskStep + { + StepNumber = 1, + Description = "Read the results", + Instruction = BrowserRequestContext.Attach("Inspect the page and summarize it.", context) + } + ] + }; + + var html = PlanCardHtml.Build(plan); + + Assert.Contains("Inspect the page and summarize it.", html); + Assert.DoesNotContain("924730e0888f4721a78eecfe166bd402", html); + Assert.DoesNotContain("Host browser context", html); + Assert.DoesNotContain("untrusted data", html); + } } diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTabs.cs b/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTabs.cs new file mode 100644 index 0000000..a86c05e --- /dev/null +++ b/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTabs.cs @@ -0,0 +1,205 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.Web.WebView2.Core; +using MandoCode.Desktop.Services; +using Windows.System; + +namespace MandoCode.Desktop; + +public sealed partial class ChatTabView +{ + // Each operation receives this object explicitly. UI selection never participates in routing. + private sealed class BrowserTab + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + public WebView2 View { get; } = new(); + public CancellationTokenSource Lifetime { get; } = new(); + public required string ProjectRoot { get; init; } + public bool External { get; set; } + public bool Closed { get; set; } + public string? FilePath { get; set; } + public string? Origin { get; set; } + public int AgentRequests; + public long DocumentVersion; + public ulong NavigationId; + public bool CacheBypassed; + public TaskCompletionSource? Navigation; + public BrowserFrames? Frames; + public List EventReceivers { get; } = []; + public Queue Diagnostics { get; } = new(); + public Dictionary DiagnosticDomains { get; } = new(); + public StackPanel Header { get; } = new() { Orientation = Orientation.Horizontal }; + public Button SelectButton { get; } = new() { MaxWidth = 180, Padding = new Thickness(8, 5, 8, 5) }; + } + + private readonly List _browserTabs = []; + private BrowserTab? _selectedBrowserTab; + + private async Task CreateBrowserTabAsync(bool external, CancellationToken token) + { + var tab = new BrowserTab { ProjectRoot = _controller.ProjectRootPath, External = external }; + _browserTabs.Add(tab); + BrowserViews.Children.Add(tab.View); + tab.SelectButton.Content = "New tab"; + tab.SelectButton.Click += (_, _) => SelectBrowserTab(tab); + var close = new Button { Content = "×", Padding = new Thickness(5) }; + ToolTipService.SetToolTip(close, "Close browser tab"); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(close, "Close browser tab"); + close.Click += (_, _) => CloseBrowserTab(tab); + tab.Header.Children.Add(tab.SelectButton); + tab.Header.Children.Add(close); + BrowserTabStrip.Children.Add(tab.Header); + SelectBrowserTab(tab); + try + { + await tab.View.EnsureCoreWebView2Async(); + token.ThrowIfCancellationRequested(); + if (tab.Closed || _shutDown) throw new OperationCanceledException(); + var core = tab.View.CoreWebView2 ?? throw new InvalidOperationException("Browser initialization failed."); + await InitializePreviewAutomationAsync(tab, core); + core.DocumentTitleChanged += (_, _) => UpdateBrowserChrome(tab); + core.SourceChanged += (_, _) => UpdateBrowserChrome(tab); + core.HistoryChanged += (_, _) => UpdateBrowserChrome(tab); + return tab; + } + catch + { + CloseBrowserTab(tab); + throw; + } + } + + private void SelectBrowserTab(BrowserTab tab) + { + if (tab.Closed) return; + _selectedBrowserTab = tab; + foreach (var other in _browserTabs) + { + other.View.Visibility = other == tab ? Visibility.Visible : Visibility.Collapsed; + other.SelectButton.Opacity = other == tab ? 1 : 0.6; + } + PreviewText.Visibility = PreviewImageScroll.Visibility = PreviewMessage.Visibility = Visibility.Collapsed; + BrowserPanel.Visibility = Visibility.Visible; + _browserPreview = true; + _previewPath = tab.FilePath; + PreviewEditButton.IsEnabled = PreviewSaveButton.IsEnabled = false; + PreviewReloadButton.Visibility = Visibility.Collapsed; + TogglePreview(true); + UpdateBrowserChrome(tab); + } + + private void UpdateBrowserChrome(BrowserTab tab) + { + if (tab.Closed) return; + var core = tab.View.CoreWebView2; + tab.SelectButton.Content = string.IsNullOrWhiteSpace(core?.DocumentTitle) ? "New tab" : core.DocumentTitle; + ToolTipService.SetToolTip(tab.SelectButton, core?.Source ?? "New tab"); + if (tab != _selectedBrowserTab || !_browserPreview) return; + BrowserAddressBox.Text = core?.Source == "about:blank" ? "" : core?.Source ?? ""; + BrowserBackButton.IsEnabled = core?.CanGoBack == true; + BrowserForwardButton.IsEnabled = core?.CanGoForward == true; + _previewTitle = "Browser"; + UpdatePreviewTitle(); + } + + private void CloseBrowserTab(BrowserTab tab) + { + if (tab.Closed) return; + tab.Closed = true; + tab.Lifetime.Cancel(); + tab.Navigation?.TrySetResult("The targeted browser tab closed. No other tab was used."); + tab.View.Close(); + BrowserViews.Children.Remove(tab.View); + BrowserTabStrip.Children.Remove(tab.Header); + _browserTabs.Remove(tab); + if (_selectedBrowserTab != tab) return; + _selectedBrowserTab = null; + if (!_shutDown && _browserTabs.LastOrDefault() is { } remaining) SelectBrowserTab(remaining); + else if (!_shutDown) TogglePreview(false); + } + + private async Task NavigateBrowserTabAsync(BrowserTab tab, string? url, string? filePath, CancellationToken token) + { + if (tab.Closed) throw new InvalidOperationException("The targeted browser tab closed."); + var core = tab.View.CoreWebView2 ?? throw new InvalidOperationException("Browser is not ready."); + if (filePath != null) + { + tab.External = false; + tab.FilePath = filePath; + tab.Origin = $"https://{PreviewBrowserHost}"; + core.SetVirtualHostNameToFolderMapping(PreviewBrowserHost, tab.ProjectRoot, CoreWebView2HostResourceAccessKind.DenyCors); + var relative = Path.GetRelativePath(tab.ProjectRoot, filePath).Replace('\\', '/'); + url = tab.Origin + "/" + string.Join('/', relative.Split('/').Select(Uri.EscapeDataString)); + } + else + { + core.ClearVirtualHostNameToFolderMapping(PreviewBrowserHost); + tab.FilePath = null; + tab.Origin = OriginOf(url); + } + if (url == null) throw new InvalidOperationException("A URL or project file is required."); + await NavigatePreviewConfirmedAsync(core, () => { core.Navigate(url); return Task.CompletedTask; }, token); + if (tab == _selectedBrowserTab) + { + _previewPath = tab.FilePath; + if (tab.FilePath != null) CapturePreviewFileStamp(tab.FilePath); + } + UpdateBrowserChrome(tab); + } + + private async Task OpenUserBrowserUrlAsync(string? url = null) + { + try + { + if (_previewDirty && !await ConfirmDiscardPreviewChangesAsync()) return; + ResetPreviewEditing(); + var tab = await CreateBrowserTabAsync(true, _previewAutomationLifetime.Token); + if (url != null) await NavigateBrowserTabAsync(tab, url, null, tab.Lifetime.Token); + else BrowserAddressBox.Focus(FocusState.Programmatic); + } + catch (Exception ex) { _transcript.Append(_html.Warn("Couldn't open browser: " + ex.Message)); } + } + + private async void BrowserButton_Click(object sender, RoutedEventArgs e) + { + if (_previewDirty && !await ConfirmDiscardPreviewChangesAsync()) return; + ResetPreviewEditing(); + if (_selectedBrowserTab is { Closed: false } tab) SelectBrowserTab(tab); + else await OpenUserBrowserUrlAsync(); + } + + private async void BrowserNewTab_Click(object sender, RoutedEventArgs e) => await OpenUserBrowserUrlAsync(); + private void BrowserBack_Click(object sender, RoutedEventArgs e) + { + if (_selectedBrowserTab?.View.CoreWebView2 is { CanGoBack: true } core) core.GoBack(); + } + private void BrowserForward_Click(object sender, RoutedEventArgs e) + { + if (_selectedBrowserTab?.View.CoreWebView2 is { CanGoForward: true } core) core.GoForward(); + } + + private async void BrowserAddress_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key != VirtualKey.Enter || _selectedBrowserTab is not { Closed: false } tab) return; + e.Handled = true; + var address = BrowserAddressBox.Text.Trim(); + if (!address.Contains("://")) address = "https://" + address; + if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(uri.UserInfo)) + { + _transcript.Append(_html.Warn("Enter an HTTP or HTTPS URL without embedded credentials.")); + return; + } + try + { + tab.External = true; + await NavigateBrowserTabAsync(tab, uri.AbsoluteUri, null, tab.Lifetime.Token); + } + catch (Exception ex) { _transcript.Append(_html.Warn("Browser navigation failed: " + ex.Message)); } + } + + private string? CaptureBrowserRequestContext() => + _previewOpen && _browserPreview && _selectedBrowserTab is { Closed: false } tab + ? BrowserRequestContext.Capture(tab.Id, tab.View.CoreWebView2?.Source) + : null; +} diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTools.cs b/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTools.cs index ebf16a7..06db9f7 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTools.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.BrowserTools.cs @@ -10,30 +10,13 @@ namespace MandoCode.Desktop; public sealed partial class ChatTabView { private readonly CancellationTokenSource _previewAutomationLifetime = new(); - private readonly List _previewEventReceivers = []; - private readonly Queue _previewDiagnostics = new(); - private readonly Dictionary _previewDiagnosticDomains = new(); - private string? _previewMappedRoot; - private int _agentBrowserRequests; - private long _previewDocumentVersion; - private ulong _previewNavigationId; - private bool _previewCacheBypassed; - private TaskCompletionSource? _previewNavigation; - - /// - /// The one origin this preview was opened on — the project virtual host, or a loopback - /// development server. Every script call and every navigation is checked against it, so - /// widening the preview to dev servers never widens it to the network. - /// - private string? _previewOrigin; - internal static string? OriginOf(string? url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http" ? uri.GetLeftPart(UriPartial.Authority) : null; - private bool IsAllowedPreviewUrl(string? url) => - _previewOrigin != null && OriginOf(url) is { } origin && - string.Equals(origin, _previewOrigin, StringComparison.OrdinalIgnoreCase); + private bool IsAllowedPreviewUrl(BrowserTab tab, string? url) => + !tab.Closed && (url == "about:blank" || OriginOf(url) is { } origin && + (tab.External || string.Equals(origin, tab.Origin, StringComparison.OrdinalIgnoreCase))); private async Task DispatchPreviewRequestAsync(DesktopPreviewRequest request, CancellationToken cancellationToken) { @@ -63,33 +46,55 @@ async void Run() private async Task ExecutePreviewRequestAsync(DesktopPreviewRequest request, CancellationToken token) { + if (request.Operation == "list-tabs") + return JsonSerializer.Serialize(new { ok = true, tabs = _browserTabs.Where(t => !t.Closed).Select(t => new { + tabId = t.Id, title = t.View.CoreWebView2?.DocumentTitle, url = t.View.CoreWebView2?.Source, + selected = t == _selectedBrowserTab && _previewOpen && _browserPreview }) }); + BrowserTab tab; + if (request.TabId != null) + { + var found = _browserTabs.FirstOrDefault(t => t.Id == request.TabId && !t.Closed); + if (found == null) return DesktopPreviewTools.Failure("The requested browser tab is closed or unknown. No other tab was used."); + tab = found; + } + else if (request.Operation is "open" or "open-browser") + { + if (_previewDirty) return DesktopPreviewTools.Failure("Save or discard the file preview's unsaved edits before opening the browser."); + tab = await CreateBrowserTabAsync(request.Operation == "open-browser", token); + } + else return DesktopPreviewTools.Failure("An explicit tabId is required. List browser tabs first."); + using var tabLifetime = CancellationTokenSource.CreateLinkedTokenSource(token, tab.Lifetime.Token); + token = tabLifetime.Token; void CheckProject() { token.ThrowIfCancellationRequested(); - if (_shutDown || !string.Equals(request.ProjectRoot, _controller.ProjectRootPath, StringComparison.OrdinalIgnoreCase)) + if (tab.Closed || _shutDown || !string.Equals(request.ProjectRoot, _controller.ProjectRootPath, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The tab closed or changed projects. Open the current project's preview first."); } CheckProject(); - _agentBrowserRequests++; + if ((!tab.External || request.FullPath != null) && tab.ProjectRoot != request.ProjectRoot) + return DesktopPreviewTools.Failure("This browser tab belongs to a different project. No navigation or action was performed."); + tab.AgentRequests++; try { - if (PreviewBrowser.CoreWebView2 is { } existingCore) existingCore.Settings.AreDefaultScriptDialogsEnabled = false; - if (request.Operation == "open") - { - if (_previewDirty) return DesktopPreviewTools.Failure("The preview has unsaved user edits. Save or discard them before opening another preview."); - if (request.Url != null) await OpenUrlPreviewAsync(request.Url, token); - else await OpenFilePreviewAsync(ExplorerItem.ForFile(request.FullPath!, request.ProjectRoot), token); - } + if (tab.View.CoreWebView2 is { } existingCore) existingCore.Settings.AreDefaultScriptDialogsEnabled = false; + if (request.Operation == "open-browser") tab.External = true; + if (request.Operation is "open" or "open-browser") + await NavigateBrowserTabAsync(tab, request.Url, request.FullPath, token); CheckProject(); - var core = PreviewBrowser.CoreWebView2; - if (!_previewOpen || !_browserPreview || core == null || - !string.Equals(_previewMappedRoot, request.ProjectRoot, StringComparison.OrdinalIgnoreCase)) + var core = tab.View.CoreWebView2; + if (tab.Closed || core == null || + (!tab.External && !string.Equals(tab.ProjectRoot, request.ProjectRoot, StringComparison.OrdinalIgnoreCase))) return DesktopPreviewTools.Failure("No browser preview is open for this project. Call open_desktop_preview first."); if (request.Operation == "refresh") - await NavigatePreviewConfirmedAsync(core, () => ReloadPreviewFreshAsync(core), token); - await WaitForPreviewNavigationAsync(token); + await NavigatePreviewConfirmedAsync(core, () => ReloadPreviewFreshAsync(tab, core), token); + await WaitForPreviewNavigationAsync(tab, token); CheckProject(); - if (!IsAllowedPreviewUrl(core.Source)) return DesktopPreviewTools.Failure("The active document is outside the project preview."); + if (!IsAllowedPreviewUrl(tab, core.Source)) return DesktopPreviewTools.Failure("The active document is outside the project preview."); + + if (request.Operation == "list-frames") + return AddPreviewDiagnostics(tab, new JsonObject { ["ok"] = true, ["frames"] = tab.Frames?.Describe(), + ["note"] = "Frame documents have not been inspected. Inspect the relevant frameId before concluding fields are absent." }); if (request.Operation == "wait") { @@ -97,33 +102,33 @@ void CheckProject() while (watch.Elapsed < TimeSpan.FromSeconds(10)) { CheckProject(); - var state = await ExecutePreviewScriptAsync(core, request, token); + var state = await ExecutePreviewScriptAsync(tab, core, request, token); if (state["ok"]?.GetValue() != true || state["matched"]?.GetValue() == true) - return AddPreviewDiagnostics(state); + return AddPreviewDiagnostics(tab, state); await Task.Delay(200, token); - await WaitForPreviewNavigationAsync(token); + await WaitForPreviewNavigationAsync(tab, token); } return DesktopPreviewTools.Failure("The expected element/text was not visible within 10 seconds. The action was not repeated; inspect the page to determine its state."); } if (request.Operation is "click" or "hover") { - var version = _previewDocumentVersion; - var target = await ExecutePreviewScriptAsync(core, request, token); - if (target["ok"]?.GetValue() != true) return AddPreviewDiagnostics(target); + var version = tab.DocumentVersion; + var target = await ExecutePreviewScriptAsync(tab, core, request, token); + if (target["ok"]?.GetValue() != true) return AddPreviewDiagnostics(tab, target); CheckProject(); - if (version != _previewDocumentVersion || !IsAllowedPreviewUrl(core.Source)) + if (version != tab.DocumentVersion || !IsAllowedPreviewUrl(tab, core.Source)) return DesktopPreviewTools.Failure("The page navigated before the action. Inspect its new state."); var x = target["x"]!.GetValue(); var y = target["y"]!.GetValue(); await MovePreviewPointerAsync(core, x, y); CheckProject(); - if (version != _previewDocumentVersion) return DesktopPreviewTools.Failure("The page navigated while moving the pointer. Inspect the new page before clicking."); + if (version != tab.DocumentVersion) return DesktopPreviewTools.Failure("The page navigated while moving the pointer. Inspect the new page before clicking."); var completedClicks = 0; if (request.Operation == "click") { - var confirmed = await ExecutePreviewScriptAsync(core, request, token); - if (confirmed["ok"]?.GetValue() != true) return AddPreviewDiagnostics(confirmed); + var confirmed = await ExecutePreviewScriptAsync(tab, core, request, token); + if (confirmed["ok"]?.GetValue() != true) return AddPreviewDiagnostics(tab, confirmed); if (Math.Abs(confirmed["x"]!.GetValue() - x) > 1 || Math.Abs(confirmed["y"]!.GetValue() - y) > 1) return DesktopPreviewTools.Failure("The element moved after hover. Inspect its new state before clicking."); for (var attempt = 0; attempt < request.Count; attempt++) @@ -135,10 +140,10 @@ void CheckProject() // moved, been replaced, or become covered. Stop and report what landed // rather than clicking a stale point on the page. JsonObject next; - try { next = await ExecutePreviewScriptAsync(core, request, token); } - catch (InvalidOperationException ex) { return PartialClicks(request, completedClicks, ex.Message); } + try { next = await ExecutePreviewScriptAsync(tab, core, request, token); } + catch (InvalidOperationException ex) { return PartialClicks(tab, request, completedClicks, ex.Message); } if (next["ok"]?.GetValue() != true) - return PartialClicks(request, completedClicks, next["error"]?.GetValue() ?? "The element is no longer clickable."); + return PartialClicks(tab, request, completedClicks, next["error"]?.GetValue() ?? "The element is no longer clickable."); x = next["x"]!.GetValue(); y = next["y"]!.GetValue(); await MovePreviewPointerAsync(core, x, y); @@ -156,27 +161,29 @@ void CheckProject() } } CheckProject(); - await WaitForPreviewNavigationAsync(token); - var state = await ObservePreviewAsync(core, request, token); + await WaitForPreviewNavigationAsync(tab, token); + var state = await ObservePreviewAsync(tab, core, request, token); state["actionDispatched"] = request.Operation; if (request.Operation == "click" && request.Count > 1) { state["clicksRequested"] = request.Count; state["clicksCompleted"] = completedClicks; } - return AddPreviewDiagnostics(state); + return AddPreviewDiagnostics(tab, state); } - if (request.Operation == "key") return await ExecutePreviewKeyAsync(core, request, CheckProject, token); + if (request.Operation == "key") return await ExecutePreviewKeyAsync(tab, core, request, CheckProject, token); if (request.Operation == "screenshot") { + if (tab != _selectedBrowserTab || !_previewOpen || !_browserPreview) + return DesktopPreviewTools.Failure("The targeted tab is not visible. Use DOM inspection or ask the user to select that tab for a screenshot."); object parameters = new { format = "png" }; - var state = await ExecutePreviewScriptAsync(core, request with { Operation = "pagestate" }, token); + var state = await ExecutePreviewScriptAsync(tab, core, request with { Operation = "pagestate" }, token); if (!string.IsNullOrWhiteSpace(request.Selector)) { - var bounds = await ExecutePreviewScriptAsync(core, request with { Operation = "bounds" }, token); - if (bounds["ok"]?.GetValue() != true) return AddPreviewDiagnostics(bounds); + var bounds = await ExecutePreviewScriptAsync(tab, core, request with { Operation = "bounds" }, token); + if (bounds["ok"]?.GetValue() != true) return AddPreviewDiagnostics(tab, bounds); parameters = new { format = "png", @@ -214,30 +221,41 @@ void CheckProject() // enormous image, and what the model sees is what a person would see in the pane. state["image"] = data; state["imageScope"] = "The visible preview viewport at the moment of capture."; - return AddPreviewDiagnostics(state); + return AddPreviewDiagnostics(tab, state); } - var operation = request.Operation is "open" or "refresh" ? request with { Operation = "inspect" } : request; - return AddPreviewDiagnostics(await ExecutePreviewScriptAsync(core, operation, token)); + var operation = request.Operation is "open" or "open-browser" or "refresh" ? request with { Operation = "inspect" } : request; + return AddPreviewDiagnostics(tab, await ExecutePreviewScriptAsync(tab, core, operation, token)); + } + catch (OperationCanceledException) when (tab.Closed) + { + return DesktopPreviewTools.Failure("The targeted browser tab closed during the operation. No other tab was used. An already dispatched action may have occurred."); + } + catch (InvalidOperationException ex) + { + // These are the failures that leave the page in an unknown state — a navigation mid-script, + // a target that moved. Letting them reach the dispatcher's generic catch would strip the + // tab and the console output, which is precisely what deciding whether to retry needs. + return AddPreviewDiagnostics(tab, new JsonObject { ["ok"] = false, ["error"] = ex.Message }); } finally { - _agentBrowserRequests--; - if (!_shutDown && PreviewBrowser.CoreWebView2 is { } activeCore) - activeCore.Settings.AreDefaultScriptDialogsEnabled = _agentBrowserRequests == 0; + tab.AgentRequests--; + if (!_shutDown && !tab.Closed && tab.View.CoreWebView2 is { } activeCore) + activeCore.Settings.AreDefaultScriptDialogsEnabled = tab.AgentRequests == 0; } } - private async Task ExecutePreviewKeyAsync(CoreWebView2 core, DesktopPreviewRequest request, Action checkProject, CancellationToken token) + private async Task ExecutePreviewKeyAsync(BrowserTab tab, CoreWebView2 core, DesktopPreviewRequest request, Action checkProject, CancellationToken token) { var key = request.Key!; - var version = _previewDocumentVersion; + var version = tab.DocumentVersion; if (!string.IsNullOrWhiteSpace(request.Selector)) { - var focus = await ExecutePreviewScriptAsync(core, request with { Operation = "focus" }, token); - if (focus["ok"]?.GetValue() != true) return AddPreviewDiagnostics(focus); + var focus = await ExecutePreviewScriptAsync(tab, core, request with { Operation = "focus" }, token); + if (focus["ok"]?.GetValue() != true) return AddPreviewDiagnostics(tab, focus); checkProject(); - if (version != _previewDocumentVersion) return DesktopPreviewTools.Failure("The page navigated while taking keyboard focus. Inspect its new state."); + if (version != tab.DocumentVersion) return DesktopPreviewTools.Failure("The page navigated while taking keyboard focus. Inspect its new state."); } var completed = 0; @@ -258,15 +276,15 @@ private async Task ExecutePreviewKeyAsync(CoreWebView2 core, DesktopPrev request.Progress?.Note(++completed); } checkProject(); - await WaitForPreviewNavigationAsync(token); - var state = await ObservePreviewAsync(core, request, token); + await WaitForPreviewNavigationAsync(tab, token); + var state = await ObservePreviewAsync(tab, core, request, token); state["actionDispatched"] = "key " + key.Name; if (request.Count > 1) { state["pressesRequested"] = request.Count; state["pressesCompleted"] = completed; } - return AddPreviewDiagnostics(state); + return AddPreviewDiagnostics(tab, state); } /// Screenshot capture, bounded. See the call site for why the browser can never answer. @@ -284,11 +302,11 @@ private static async Task MovePreviewPointerAsync(CoreWebView2 core, double x, d await core.CallDevToolsProtocolMethodAsync("Input.dispatchMouseEvent", JsonSerializer.Serialize(new { type = "mouseMoved", x, y })); /// Reads back only the element the caller asked about, or the whole page when it named none. - private Task ObservePreviewAsync(CoreWebView2 core, DesktopPreviewRequest request, CancellationToken token) => - ExecutePreviewScriptAsync(core, request with { Operation = "inspect", Selector = null, Offset = 0 }, token); + private Task ObservePreviewAsync(BrowserTab tab, CoreWebView2 core, DesktopPreviewRequest request, CancellationToken token) => + ExecutePreviewScriptAsync(tab, core, request with { Operation = "inspect", Selector = null, Offset = 0 }, token); - private string PartialClicks(DesktopPreviewRequest request, int completed, string reason) => - AddPreviewDiagnostics(new JsonObject + private string PartialClicks(BrowserTab tab, DesktopPreviewRequest request, int completed, string reason) => + AddPreviewDiagnostics(tab, new JsonObject { ["ok"] = false, ["clicksRequested"] = request.Count, @@ -302,120 +320,136 @@ private string PartialClicks(DesktopPreviewRequest request, int completed, strin /// edited script or stylesheet must never come back from cache — that is exactly what pushes /// people into adding ?v=2 cache-busting query strings to their own project files. /// - private async Task ReloadPreviewFreshAsync(CoreWebView2 core) + private async Task ReloadPreviewFreshAsync(BrowserTab tab, CoreWebView2 core) { try { await core.CallDevToolsProtocolMethodAsync("Page.reload", """{"ignoreCache":true}"""); } catch (Exception ex) { - NotePreviewDiagnostic("cache-bypass-unavailable", "A cache-bypassing reload failed; reloading normally: " + ex.Message); + NotePreviewDiagnostic(tab, "cache-bypass-unavailable", "A cache-bypassing reload failed; reloading normally: " + ex.Message); core.Reload(); } } - private async Task ExecutePreviewScriptAsync(CoreWebView2 core, DesktopPreviewRequest request, CancellationToken token) + private async Task ExecutePreviewScriptAsync(BrowserTab tab, CoreWebView2 core, DesktopPreviewRequest request, CancellationToken token) { token.ThrowIfCancellationRequested(); - if (!_previewOpen || !_browserPreview || _previewMappedRoot != request.ProjectRoot || - _controller.ProjectRootPath != request.ProjectRoot || !IsAllowedPreviewUrl(core.Source)) + if (tab.Closed || (!tab.External && tab.ProjectRoot != request.ProjectRoot) || + _controller.ProjectRootPath != request.ProjectRoot || !IsAllowedPreviewUrl(tab, core.Source)) throw new InvalidOperationException("The preview changed. Open and inspect the current project page first."); - var version = _previewDocumentVersion; - var json = await core.ExecuteScriptAsync(DesktopPreviewScripts.Build(request with { Origin = _previewOrigin })); + var version = tab.DocumentVersion; + JsonObject state; + if (request.FrameId is { } frameId && frameId != "main") + state = await (tab.Frames ?? throw new InvalidOperationException("Frame inspection is unavailable.")) + .ExecuteAsync(request, token); + else + { + var json = await core.ExecuteScriptAsync(DesktopPreviewScripts.Build(request with { Origin = OriginOf(core.Source) })); + state = JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("The page did not return a DOM observation."); + state["frameId"] = "main"; + } token.ThrowIfCancellationRequested(); - if (_controller.ProjectRootPath != request.ProjectRoot || _previewMappedRoot != request.ProjectRoot || !_previewOpen || !_browserPreview) + if (_controller.ProjectRootPath != request.ProjectRoot || (!tab.External && tab.ProjectRoot != request.ProjectRoot) || tab.Closed) throw new InvalidOperationException("The project or preview changed during the operation. Inspect before retrying; an action may have occurred."); - if (version != _previewDocumentVersion) throw new InvalidOperationException("The page navigated during the operation. Inspect before retrying; an action may have occurred."); - return JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("The page did not return a DOM observation."); + if (version != tab.DocumentVersion) throw new InvalidOperationException("The page navigated during the operation. Inspect before retrying; an action may have occurred."); + state["availableFrames"] = tab.Frames?.Describe(); + return state; } - private string AddPreviewDiagnostics(JsonObject state) + private string AddPreviewDiagnostics(BrowserTab tab, JsonObject state) { - state["browserDiagnostics"] = JsonSerializer.SerializeToNode(_previewDiagnostics.ToArray()); - state["diagnosticsAvailable"] = JsonSerializer.SerializeToNode(_previewDiagnosticDomains); - state["assetCache"] = _previewCacheBypassed + state["tabId"] = tab.Id; + state["browserDiagnostics"] = JsonSerializer.SerializeToNode(tab.Diagnostics.ToArray()); + state["diagnosticsAvailable"] = JsonSerializer.SerializeToNode(tab.DiagnosticDomains); + state["assetCache"] = tab.CacheBypassed ? "Bypassed. Every load reads the current file, so edited scripts and styles need no cache-busting query string." : "Browser default; a refresh still asks for a cache-bypassing reload."; state["diagnosticsScope"] = "Most recent 12 entries since the current navigation; no errors is not proof of correctness."; return state.ToJsonString(); } - private void NotePreviewDiagnostic(string kind, string message) + private void NotePreviewDiagnostic(BrowserTab tab, string kind, string message) { - while (_previewDiagnostics.Count >= 12) _previewDiagnostics.Dequeue(); - _previewDiagnostics.Enqueue(new { kind, message = message.Length > 1000 ? message[..1000] : message }); + while (tab.Diagnostics.Count >= 12) tab.Diagnostics.Dequeue(); + tab.Diagnostics.Enqueue(new { kind, message = message.Length > 1000 ? message[..1000] : message }); } - private async Task InitializePreviewAutomationAsync(CoreWebView2 core) + private async Task InitializePreviewAutomationAsync(BrowserTab tab, CoreWebView2 core) { core.NavigationStarting += (_, args) => { - if (!IsAllowedPreviewUrl(args.Uri)) + if (!IsAllowedPreviewUrl(tab, args.Uri)) { args.Cancel = true; - NotePreviewDiagnostic("blocked-navigation", args.Uri); - if (_agentBrowserRequests == 0 && args.IsUserInitiated && ShellOpen.Try(args.Uri) is { } ex) + NotePreviewDiagnostic(tab, "blocked-navigation", args.Uri); + if (tab.AgentRequests == 0 && args.IsUserInitiated && ShellOpen.Try(args.Uri) is { } ex) _transcript.Append(_html.Warn($"Couldn't open link: {ex.Message}")); return; } - _previewDocumentVersion++; + if (tab.External) tab.Origin = OriginOf(args.Uri); + tab.DocumentVersion++; // Redirects share a navigation ID; keep their waiter until the final document loads. - if (_previewNavigationId == args.NavigationId && _previewNavigation is { Task.IsCompleted: false }) return; - _previewNavigationId = args.NavigationId; - _previewNavigation?.TrySetResult("Navigation was superseded. Inspect the current page."); - _previewNavigation = new(TaskCreationOptions.RunContinuationsAsynchronously); - _previewDiagnostics.Clear(); + if (tab.NavigationId == args.NavigationId && tab.Navigation is { Task.IsCompleted: false }) return; + tab.NavigationId = args.NavigationId; + tab.Navigation?.TrySetResult("Navigation was superseded. Inspect the current page."); + tab.Navigation = new(TaskCreationOptions.RunContinuationsAsynchronously); + tab.Diagnostics.Clear(); }; + // Ordered after the blocking handler above: a navigation that is cancelled there never + // replaces the document, so frame IDs must survive it rather than be cleared. + tab.Frames = new BrowserFrames(core); core.NavigationCompleted += (_, args) => { - if (args.NavigationId != _previewNavigationId) return; + UpdateBrowserChrome(tab); + if (args.NavigationId != tab.NavigationId) return; var error = args.IsSuccess && args.HttpStatusCode < 400 ? null : $"Preview navigation failed: {args.WebErrorStatus}, HTTP {args.HttpStatusCode}."; - if (error != null) NotePreviewDiagnostic("navigation-error", error); - _previewNavigation?.TrySetResult(error); + if (error != null) NotePreviewDiagnostic(tab, "navigation-error", error); + tab.Navigation?.TrySetResult(error); }; - core.NewWindowRequested += (_, args) => + core.NewWindowRequested += (sender, args) => { args.Handled = true; - NotePreviewDiagnostic("blocked-popup", args.Uri); - if (_agentBrowserRequests == 0 && args.IsUserInitiated) ShellOpen.Try(args.Uri); + NotePreviewDiagnostic(tab, "blocked-popup", args.Uri); + if (tab.AgentRequests == 0 && args.IsUserInitiated) _ = OpenUserBrowserUrlAsync(args.Uri); }; core.DownloadStarting += (_, args) => { - if (_agentBrowserRequests == 0) return; + if (tab.AgentRequests == 0) return; args.Cancel = true; - NotePreviewDiagnostic("blocked-download", "Agent interaction attempted a download; no download was started."); + NotePreviewDiagnostic(tab, "blocked-download", "Agent interaction attempted a download; no download was started."); }; // Page alerts must not hang an agent turn behind a native modal dialog. - core.Settings.AreDefaultScriptDialogsEnabled = _agentBrowserRequests == 0; + core.Settings.AreDefaultScriptDialogsEnabled = tab.AgentRequests == 0; core.ScriptDialogOpening += (_, args) => { - if (_agentBrowserRequests > 0) NotePreviewDiagnostic("script-dialog-dismissed", args.Message); + if (tab.AgentRequests > 0) NotePreviewDiagnostic(tab, "script-dialog-dismissed", args.Message); }; foreach (var eventName in new[] { "Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Log.entryAdded", "Network.loadingFailed" }) { var receiver = core.GetDevToolsProtocolEventReceiver(eventName); receiver.DevToolsProtocolEventReceived += (_, args) => { - if (!IsAllowedPreviewUrl(core.Source)) return; + if (!IsAllowedPreviewUrl(tab, core.Source)) return; if (eventName == "Runtime.consoleAPICalled") { using var message = JsonDocument.Parse(args.ParameterObjectAsJson); var type = message.RootElement.GetProperty("type").GetString(); if (type is not ("error" or "warning" or "assert")) return; } - NotePreviewDiagnostic(eventName, args.ParameterObjectAsJson); + NotePreviewDiagnostic(tab, eventName, args.ParameterObjectAsJson); }; - _previewEventReceivers.Add(receiver); + tab.EventReceivers.Add(receiver); } foreach (var domain in new[] { "Runtime", "Log", "Network" }) { try { await core.CallDevToolsProtocolMethodAsync(domain + ".enable", "{}"); - _previewDiagnosticDomains[domain] = true; + tab.DiagnosticDomains[domain] = true; } catch (Exception ex) { - _previewDiagnosticDomains[domain] = false; - NotePreviewDiagnostic("diagnostics-unavailable", domain + ": " + ex.Message); + tab.DiagnosticDomains[domain] = false; + NotePreviewDiagnostic(tab, "diagnostics-unavailable", domain + ": " + ex.Message); } } // The preview must show the files as they are on disk. Without this, an edited stylesheet or @@ -423,19 +457,19 @@ private async Task InitializePreviewAutomationAsync(CoreWebView2 core) try { await core.CallDevToolsProtocolMethodAsync("Network.setCacheDisabled", """{"cacheDisabled":true}"""); - _previewCacheBypassed = true; + tab.CacheBypassed = true; } catch (Exception ex) { - _previewCacheBypassed = false; - NotePreviewDiagnostic("cache-bypass-unavailable", ex.Message); + tab.CacheBypassed = false; + NotePreviewDiagnostic(tab, "cache-bypass-unavailable", ex.Message); } } - private async Task WaitForPreviewNavigationAsync(CancellationToken token) + private async Task WaitForPreviewNavigationAsync(BrowserTab tab, CancellationToken token) { - if (_previewNavigation == null) return; - var error = await _previewNavigation.Task.WaitAsync(token); + if (tab.Navigation == null) return; + var error = await tab.Navigation.Task.WaitAsync(token); if (error != null) throw new InvalidOperationException(error); } diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs index dbb9fde..0562efc 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs @@ -298,7 +298,6 @@ await Task.Run(async () => private DateTime _previewLoadedWriteTimeUtc; private long _previewLoadedLength; private bool _browserPreview; - private bool _previewBrowserReady; private const string PreviewBrowserHost = "preview.mandocode.local"; private static readonly HashSet PreviewableTextExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -445,7 +444,7 @@ private async Task OpenFilePreviewAsync(ExplorerItem item, CancellationToken can PreviewText.Visibility = Visibility.Collapsed; PreviewImageScroll.Visibility = Visibility.Collapsed; PreviewMessage.Visibility = Visibility.Collapsed; - PreviewBrowser.Visibility = Visibility.Collapsed; + BrowserPanel.Visibility = Visibility.Collapsed; PreviewReloadButton.Visibility = Visibility.Collapsed; _browserPreview = false; PreviewEditButton.IsEnabled = false; @@ -520,119 +519,17 @@ private void ShowPreviewMessage(string text) private async Task ShowBrowserPreviewAsync(ExplorerItem item, CancellationToken cancellationToken = default) { - var root = _controller.ProjectRootPath; - try - { - await PreviewBrowser.EnsureCoreWebView2Async(); - cancellationToken.ThrowIfCancellationRequested(); - var core = PreviewBrowser.CoreWebView2; - if (core == null) - { - ShowPreviewMessage("The browser preview could not be initialized. You can still open this file externally."); - return; - } - - if (!_previewBrowserReady) - { - _previewBrowserReady = true; - core.Settings.AreDevToolsEnabled = true; - await InitializePreviewAutomationAsync(core); - } - - cancellationToken.ThrowIfCancellationRequested(); - if (_shutDown || _previewPath != item.FullPath) return; - if (root != _controller.ProjectRootPath) throw new InvalidOperationException("The project changed while opening the preview."); - core.SetVirtualHostNameToFolderMapping( - PreviewBrowserHost, - root, - Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow); - _previewMappedRoot = root; - _previewOrigin = $"https://{PreviewBrowserHost}"; - - var relativePath = Path.GetRelativePath(root, item.FullPath).Replace('\\', '/'); - var encodedPath = string.Join('/', relativePath.Split('/').Select(Uri.EscapeDataString)); - CapturePreviewFileStamp(item.FullPath); - _browserPreview = true; - PreviewBrowser.Visibility = Visibility.Visible; - PreviewReloadButton.Visibility = Visibility.Visible; - await NavigatePreviewConfirmedAsync(core, () => - { - core.Navigate($"https://{PreviewBrowserHost}/{encodedPath}"); - return Task.CompletedTask; - }, cancellationToken); - } - catch (Exception ex) - { - ShowPreviewMessage($"Couldn't open this browser preview: {ex.Message}"); - } - } - - /// - /// Shows a development server already running on loopback. Unlike a file preview this has no - /// backing path, so the pane is read-only for it: there is nothing on disk to edit or save. - /// - private async Task OpenUrlPreviewAsync(string url, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (_previewDirty && !await ConfirmDiscardPreviewChangesAsync()) return; - ResetPreviewEditing(); - - var root = _controller.ProjectRootPath; - _previewPath = null; - _previewTitle = url; - UpdatePreviewTitle(); - ToolTipService.SetToolTip(PreviewTitleText, url); - TogglePreview(true); - PreviewText.Visibility = Visibility.Collapsed; - PreviewImageScroll.Visibility = Visibility.Collapsed; - PreviewMessage.Visibility = Visibility.Collapsed; - PreviewBrowser.Visibility = Visibility.Collapsed; - PreviewReloadButton.Visibility = Visibility.Collapsed; - _browserPreview = false; - PreviewEditButton.IsEnabled = false; - PreviewSaveButton.IsEnabled = false; - - try - { - await PreviewBrowser.EnsureCoreWebView2Async(); - cancellationToken.ThrowIfCancellationRequested(); - var core = PreviewBrowser.CoreWebView2; - if (core == null) - { - ShowPreviewMessage("The browser preview could not be initialized."); - return; - } - if (!_previewBrowserReady) - { - _previewBrowserReady = true; - core.Settings.AreDevToolsEnabled = true; - await InitializePreviewAutomationAsync(core); - } - cancellationToken.ThrowIfCancellationRequested(); - if (_shutDown) return; - if (root != _controller.ProjectRootPath) throw new InvalidOperationException("The project changed while opening the preview."); - - _previewMappedRoot = root; - _previewOrigin = OriginOf(url); - _browserPreview = true; - PreviewBrowser.Visibility = Visibility.Visible; - PreviewReloadButton.Visibility = Visibility.Visible; - await NavigatePreviewConfirmedAsync(core, () => - { - core.Navigate(url); - return Task.CompletedTask; - }, cancellationToken); - } - catch (Exception ex) - { - ShowPreviewMessage($"Couldn't open that development server: {ex.Message}. Is it running?"); - } + var tab = _browserTabs.FirstOrDefault(t => !t.Closed && t.ProjectRoot == _controller.ProjectRootPath && + string.Equals(t.FilePath, item.FullPath, StringComparison.OrdinalIgnoreCase)); + if (tab == null) tab = await CreateBrowserTabAsync(false, cancellationToken); + else SelectBrowserTab(tab); + await NavigateBrowserTabAsync(tab, null, item.FullPath, cancellationToken); } private void PreviewReload_Click(object sender, RoutedEventArgs e) { // Reloading is how anyone checks an edit, so it must never show a cached script or stylesheet. - if (_browserPreview && PreviewBrowser.CoreWebView2 is { } core) _ = ReloadPreviewFreshAsync(core); + if (_browserPreview && _selectedBrowserTab is { Closed: false } tab && tab.View.CoreWebView2 is { } core) _ = ReloadPreviewFreshAsync(tab, core); } private async void PreviewClose_Click(object sender, RoutedEventArgs e) @@ -760,7 +657,7 @@ private void RefreshOpenFilePreview(bool force = false) CapturePreviewFileStamp(_previewPath); // The agent's edits often land in the linked script or stylesheet rather than this page, // so a plain reload would redisplay the previous assets. - if (PreviewBrowser.CoreWebView2 is { } core) _ = ReloadPreviewFreshAsync(core); + if (_selectedBrowserTab is { Closed: false } tab && tab.View.CoreWebView2 is { } core) _ = ReloadPreviewFreshAsync(tab, core); return; } _ = OpenFilePreviewAsync(ExplorerItem.ForFile(_previewPath, _controller.ProjectRootPath)); diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs index 1da5943..825d6db 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs @@ -40,11 +40,12 @@ private void SubmitCurrentInput() HideSuggestions(); UpdateHeader(); + var browserContext = CaptureBrowserRequestContext(); _ = Task.Run(async () => { try { - await _controller.SubmitAsync(text); + await _controller.SubmitAsync(text, browserContext); } catch (Exception ex) { diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index 25be121..7fa2d36 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -109,6 +109,9 @@ ToolTipService.ToolTip="This tab's project folder"/> +