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"/> +