From da39ad394e58748656e75de091c31974829de81b Mon Sep 17 00:00:00 2001 From: PieMH Date: Sun, 18 Oct 2026 22:55:07 +0200 Subject: [PATCH 1/5] Browser panel tabs: tab strip on one shared profile, popups open a new tab, tab tools for Claude Every tab is its own WebView2 on the panel's one CoreWebView2Environment. window.open and target=_blank open a new tab in front (still gated by SchemeAllowed, max 20 tabs). The Browser tool gains tabs, new_tab, switch_tab and close_tab; the other tools act on the front tab, captured once per call, with both page checks on that tab. --- src/CCWrapper/Terminal/BrowserMcp.cs | 53 ++++- src/CCWrapper/Terminal/Smoke.cs | 37 ++- src/CCWrapper/Theme.xaml | 31 +++ src/CCWrapper/Views/BrowserPanel.Driver.cs | 145 ++++++++---- src/CCWrapper/Views/BrowserPanel.xaml | 18 +- src/CCWrapper/Views/BrowserPanel.xaml.cs | 220 +++++++++++++++--- .../Terminal/BrowserMcpTests.cs | 39 +++- .../CCWrapper.Tests/Views/PanelLogicTests.cs | 65 ++++++ 8 files changed, 510 insertions(+), 98 deletions(-) diff --git a/src/CCWrapper/Terminal/BrowserMcp.cs b/src/CCWrapper/Terminal/BrowserMcp.cs index d75631f..6f6f9f9 100644 --- a/src/CCWrapper/Terminal/BrowserMcp.cs +++ b/src/CCWrapper/Terminal/BrowserMcp.cs @@ -9,7 +9,9 @@ namespace CCWrapper.Terminal; /// limit is abandoned (its token cancelled) and the next one may start while it lingers. A failure throws, and the /// exception's message is what Claude reads, so make it say what went wrong ("No element matches #login"). /// A target that is all ASCII digits is a ref number from the latest ; anything -/// else is a CSS selector, and the first element it matches is used. A ref that no longer exists throws. +/// else is a CSS selector, and the first element it matches is used. A ref that no longer exists throws. +/// The panel has tabs of its own, each with an id (1, 2, 3…, never reused). Every member but the four tab members acts on +/// the front tab; a page's target=_blank or window.open opens a new tab in front. public interface IBrowserDriver { /// Opens and waits for the page to load. Returns a short text for Claude, e.g. the final @@ -37,6 +39,18 @@ public interface IBrowserDriver /// Calls one Chrome DevTools Protocol method on the page ( is a JSON object) and /// returns its JSON result. Task DevToolsAsync(string method, string paramsJson, CancellationToken ct); + + /// The open tabs as text: id, title and URL of each, and which one is in front. + Task TabsAsync(CancellationToken ct); + + /// Opens a new tab in front, loads in it and waits for the page to load. + Task NewTabAsync(string url, CancellationToken ct); + + /// Brings tab to front. An unknown id throws. + Task SwitchTabAsync(int id, CancellationToken ct); + + /// Closes tab ; closing the last one leaves one blank tab. An unknown id throws. + Task CloseTabAsync(int id, CancellationToken ct); } /// The MCP server of the Browser tool, without its transport: one newline-delimited JSON-RPC 2.0 message in, zero @@ -136,6 +150,10 @@ async Task CallAsync(JsonNode? id, JsonObject? p, CancellationToken ct) }), (a, c) => DevToolsRefusal(a.Method) is { } why ? Task.FromException(new InvalidOperationException(why)) : driver.DevToolsAsync(a.Method, a.Params, c)), + "tabs" => c => TextResult(driver.TabsAsync(c)), + "new_tab" => Run(args["url"] is null ? "about:blank" : Arg(args, "url"), driver.NewTabAsync), + "switch_tab" => Run(Id(args), driver.SwitchTabAsync), + "close_tab" => Run(Id(args), driver.CloseTabAsync), var name => throw new ArgumentException("Unknown tool: " + name), }; } @@ -163,6 +181,9 @@ JsonValue v when v.GetValueKind() == JsonValueKind.Number => v.ToJsonString(), _ => throw new ArgumentException($"Missing or invalid '{key}'."), }; + static int Id(JsonObject args) => args["id"] is JsonValue v && v.GetValueKind() == JsonValueKind.Number && v.TryGetValue(out int id) + ? id : throw new ArgumentException("Missing or invalid 'id': a tab id (an integer) from the tabs tool."); + static Func> Run(T args, Func> call) => c => TextResult(call(args, c)); static string? Text(JsonNode? node) => node is JsonValue v && v.TryGetValue(out string? s) ? s : null; @@ -184,35 +205,49 @@ static string Result(JsonNode? id, JsonNode result) => static string Error(JsonNode? id, int code, string message) => new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id?.DeepClone(), ["error"] = new JsonObject { ["code"] = code, ["message"] = message } }.ToJsonString(); + const string TabId = """{ "type": "integer", "description": "A tab id from the tabs tool." }"""; + const string Target = """{ "type": "string", "description": "A ref number from the latest snapshot (e.g. \"12\"), or a CSS selector." }"""; const string Tools = $$""" [ { "name": "navigate", - "description": "Open a URL in CCWrapper's Browser panel (the browser inside this app, beside the terminal) and wait for it to load.", + "description": "Open a URL in the front tab of CCWrapper's Browser panel (the browser inside this app, beside the terminal) and wait for it to load. The panel has tabs: every tool but tabs, new_tab, switch_tab and close_tab acts on the front tab, and a link or window.open that opens a new window opens a new tab in front.", "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "The address to open, e.g. https://example.com." } }, "required": ["url"] } }, { "name": "snapshot", - "description": "Read the current page as text: headings, text, links, buttons and form fields, each element you can click or type into tagged with a ref number like [12]. Take a new snapshot after the page changes: each one renumbers the refs.", + "description": "Read the front tab's page as text: headings, text, links, buttons and form fields, each element you can click or type into tagged with a ref number like [12]. Take a new snapshot after the page changes: each one renumbers the refs.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "click", - "description": "Click an element of the current page.", + "description": "Click an element of the front tab's page. If it opens a new tab, that tab comes to front and the result says so.", "inputSchema": { "type": "object", "properties": { "target": {{Target}} }, "required": ["target"] } }, { "name": "type", - "description": "Replace the value of an input, textarea or editable element with text, as if typed. Set submit to press Enter afterwards.", + "description": "Replace the value of an input, textarea or editable element of the front tab's page with text, as if typed. Set submit to press Enter afterwards.", "inputSchema": { "type": "object", "properties": { "target": {{Target}}, "text": { "type": "string", "description": "The text to type." }, "submit": { "type": "boolean", "description": "Press Enter after typing. Default false." } }, "required": ["target", "text"] } }, { "name": "screenshot", - "description": "Capture the visible part of the current page as a PNG image.", + "description": "Capture the visible part of the front tab's page as a PNG image.", "inputSchema": { "type": "object", "properties": {} } }, { "name": "evaluate", - "description": "Run a JavaScript expression in the current page and return its value as JSON. A returned Promise is awaited.", + "description": "Run a JavaScript expression in the front tab's page and return its value as JSON. A returned Promise is awaited.", "inputSchema": { "type": "object", "properties": { "expression": { "type": "string", "description": "The JavaScript expression, e.g. document.title." } }, "required": ["expression"] } }, { "name": "devtools", - "description": "Call a Chrome DevTools Protocol method on the current page (e.g. Emulation.setDeviceMetricsOverride, Network.getResponseBody) and return its JSON result. For what the other tools cannot do. {{DevToolsLimits}}", + "description": "Call a Chrome DevTools Protocol method on the front tab's page (e.g. Emulation.setDeviceMetricsOverride, Network.getResponseBody) and return its JSON result. For what the other tools cannot do. {{DevToolsLimits}}", "inputSchema": { "type": "object", "properties": { "method": { "type": "string", "description": "The CDP method, Domain.method." }, - "params": { "type": "object", "description": "The method's parameters. Default {}." } }, "required": ["method"] } } + "params": { "type": "object", "description": "The method's parameters. Default {}." } }, "required": ["method"] } }, + { "name": "tabs", + "description": "List the Browser panel's tabs: the id, title and URL of each, and which one is in front (the one the other tools act on). A tab showing a page that is not a web address lists as (not a web page).", + "inputSchema": { "type": "object", "properties": {} } }, + { "name": "new_tab", + "description": "Open a new tab in front and load a URL in it; the other tools then act on it. At most 20 tabs.", + "inputSchema": { "type": "object", "properties": { "url": { "type": "string", "description": "The address to open, e.g. https://example.com. Default about:blank." } } } }, + { "name": "switch_tab", + "description": "Bring a tab to front, so the other tools act on it.", + "inputSchema": { "type": "object", "properties": { "id": {{TabId}} }, "required": ["id"] } }, + { "name": "close_tab", + "description": "Close a tab. Closing the last tab leaves one blank tab.", + "inputSchema": { "type": "object", "properties": { "id": {{TabId}} }, "required": ["id"] } } ] """; } diff --git a/src/CCWrapper/Terminal/Smoke.cs b/src/CCWrapper/Terminal/Smoke.cs index abeaf2e..5475384 100644 --- a/src/CCWrapper/Terminal/Smoke.cs +++ b/src/CCWrapper/Terminal/Smoke.cs @@ -81,12 +81,13 @@ static async Task TalkAsync(Process relay) var list = await Ask("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); var call = await Ask("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"snapshot","arguments":{}}}"""); return init?["result"]?["serverInfo"]?["name"]?.GetValue() == "ccwrapper-browser" - && list?["result"]?["tools"]?.AsArray().Count == 7 + && list?["result"]?["tools"]?.AsArray().Count == 11 && call?["id"]?.GetValue() == 3 && call?["result"]?["content"]?[0]?["text"]?.GetValue() == "smoke-snapshot"; } - // The real Browser panel in a throwaway profile: snapshot refs, typing, clicking and evaluating on a data: page. + // The real Browser panel in a throwaway profile: snapshot refs, typing, clicking and evaluating on a data: page; then tabs: + // a window.open that becomes a tab with its opener's window object, new_tab, switch_tab and close_tab. static async Task PanelAsync() { var dir = Path.Combine(Path.GetTempPath(), "ccw-smoke-browser"); @@ -99,14 +100,15 @@ static async Task PanelAsync() ShowInTaskbar = false, ShowActivated = false, }.Show(); var run = PanelStepsAsync(panel); - if (await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(40))) == run && await run) return true; + if (await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(60))) == run && await run) return true; Console.Error.WriteLine("smoke: the Browser panel steps failed"); return false; } static async Task PanelStepsAsync(IBrowserDriver panel) { - const string html = "

Smoke

link"; + const string html = "

Smoke

link" + + ""; await panel.NavigateAsync("data:text/html," + Uri.EscapeDataString(html), default); var snap = await panel.SnapshotAsync(default); var button = System.Text.RegularExpressions.Regex.Match(snap, @"\[(\d+)\] button ""Go"""); @@ -117,7 +119,28 @@ static async Task PanelStepsAsync(IBrowserDriver panel) var title = await panel.EvaluateAsync("document.title", default); try { await panel.ClickAsync("999", default); return false; } catch (InvalidOperationException ex) when (ex.Message.StartsWith("No element matches")) { } - return title == "\"clicked:hello\""; + if (title != "\"clicked:hello\"") return false; + return await TabStepsAsync(panel); + } + + static async Task TabStepsAsync(IBrowserDriver panel) + { + if (!(await panel.ClickAsync("#pop", default)).Contains("Tab 2 is now in front") || !(await panel.TabsAsync(default)).Contains("Tab 2 (front): ")) return false; + await panel.SwitchTabAsync(1, default); + if (await panel.EvaluateAsync("!!window.w && !window.w.closed", default) != "true") return false; // the popup tab kept its opener link + var opened = await panel.NewTabAsync("data:text/html," + Uri.EscapeDataString("Two

Second

"), default); + if (!opened.StartsWith("Opened tab 3 in front. Loaded") || await panel.EvaluateAsync("document.title", default) != "\"Two\"") return false; + await panel.SwitchTabAsync(1, default); + await panel.SwitchTabAsync(3, default); + var tabs = await panel.TabsAsync(default); + if (!tabs.Contains("Tab 1: clicked:hello - data:") || !tabs.Contains("Tab 3 (front): Two - data:")) return false; + await panel.CloseTabAsync(3, default); + await panel.CloseTabAsync(2, default); + var left = await panel.TabsAsync(default); + if (left.Contains("Tab 2") || left.Contains("Tab 3") || !left.Contains("Tab 1 (front)")) return false; + try { await panel.SwitchTabAsync(3, default); return false; } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("There is no tab 3")) { } + return (await panel.SnapshotAsync(default)).Contains("# Smoke"); } sealed class SmokeDriver : IBrowserDriver @@ -129,5 +152,9 @@ sealed class SmokeDriver : IBrowserDriver public Task ScreenshotAsync(CancellationToken ct) => throw new NotSupportedException(); public Task EvaluateAsync(string expression, CancellationToken ct) => throw new NotSupportedException(); public Task DevToolsAsync(string method, string paramsJson, CancellationToken ct) => throw new NotSupportedException(); + public Task TabsAsync(CancellationToken ct) => throw new NotSupportedException(); + public Task NewTabAsync(string url, CancellationToken ct) => throw new NotSupportedException(); + public Task SwitchTabAsync(int id, CancellationToken ct) => throw new NotSupportedException(); + public Task CloseTabAsync(int id, CancellationToken ct) => throw new NotSupportedException(); } } diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 1818967..dcf18b7 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -332,6 +332,37 @@ + + +