diff --git a/Cargo.lock b/Cargo.lock index fcc4c53a9e..71d90b6bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2507,6 +2507,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "objc2-user-notifications", + "objc2-web-kit", "open", "png 0.18.1", "portable-pty", @@ -2906,6 +2907,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -2931,6 +2942,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2977,6 +2999,8 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-javascript-core", + "objc2-security", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 339804c406..e4f5fe09e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] } +tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "unstable"] } tauri-plugin-opener = "2" open = "5.4.1" serde.workspace = true @@ -55,9 +55,10 @@ keyring = { version = "3.6", default-features = false, features = ["apple-native tauri = { version = "2", features = ["tray-icon"] } block2 = "0.6.2" objc2 = { version = "0.6", features = ["exception"] } -objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSAttributedString", "NSBezierPath", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSEvent", "NSGraphics", "NSImage", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSPanel", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSUserInterfaceItemIdentification", "NSView", "NSVisualEffectView", "NSWindow", "block2", "objc2-core-foundation"] } -objc2-foundation = { version = "0.3", features = ["NSAttributedString", "NSDictionary", "NSGeometry", "NSError", "NSString"] } +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSAttributedString", "NSBezierPath", "NSBitmapImageRep", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSPanel", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSUserInterfaceItemIdentification", "NSView", "NSVisualEffectView", "NSWindow", "block2", "objc2-core-foundation"] } +objc2-foundation = { version = "0.3", features = ["NSAttributedString", "NSData", "NSDictionary", "NSGeometry", "NSError", "NSString"] } objc2-quartz-core = { version = "0.3", default-features = false, features = ["CALayer", "objc2-core-foundation", "objc2-core-graphics"] } +objc2-web-kit = { version = "0.3", features = ["WKWebView", "WKSnapshotConfiguration", "block2", "objc2-app-kit"] } objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "UNNotification", "UNNotificationAction", "UNNotificationCategory", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationSound", "block2"] } raw-window-handle = "0.6" tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs new file mode 100644 index 0000000000..9be8e048d4 --- /dev/null +++ b/src-tauri/src/browser.rs @@ -0,0 +1,1167 @@ +//! Embedded browser tabs. Each tab is a native child webview laid over a DOM +//! placeholder in its workspace window; the page owns layout and tells us +//! where to draw. Browsed pages never get IPC: they load remote origins, and +//! no capability grants remote URLs access. +use std::sync::{mpsc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::webview::{NewWindowResponse, PageLoadEvent}; +use tauri::{ + AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Url, Webview, WebviewBuilder, + WebviewUrl, Window, +}; + +const LABEL_PREFIX: &str = "browser-"; +const EVENT: &str = "browser-event"; +const CONSOLE_CAPTURE: &str = include_str!("browser_console.js"); +const EVAL_TIMEOUT_MAX: Duration = Duration::from_secs(25); +const EVAL_POLL: Duration = Duration::from_millis(40); + +/// Serializes "is the tab already open?" with creating its child webview, so +/// two opens of one id cannot both miss the lookup and race on the label. +static OPEN_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Copy, Deserialize)] +pub struct Bounds { + x: f64, + y: f64, + width: f64, + height: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +enum BrowserEvent { + /// A main-frame load started or finished at `url`. + Load { + id: String, + url: String, + loading: bool, + }, + Title { + id: String, + title: String, + }, + /// The page asked for a new window (`target=_blank`, `window.open`). + OpenTab { + id: String, + url: String, + }, +} + +fn label(id: &str) -> Result { + let valid = !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if !valid { + return Err("Invalid browser tab id".into()); + } + Ok(format!("{LABEL_PREFIX}{id}")) +} + +/// Only web pages. `file:`, `tauri:`, `javascript:` and friends stay out. +fn parse_url(value: &str) -> Result { + let url = Url::parse(value.trim()).map_err(|_| "Invalid URL".to_string())?; + match url.scheme() { + "http" | "https" => Ok(url), + "about" if url.as_str() == "about:blank" => Ok(url), + scheme => Err(format!("Unsupported URL scheme: {scheme}")), + } +} + +/// The app's own origins: the dev server and any external URL a window is +/// configured with. Read from config, not from live webviews: `Webview::url` +/// can fail (and panics on macOS before the native URL exists). +fn app_origins(app: &AppHandle) -> Vec { + app_origins_from(app.config(), tauri::is_dev()) +} + +/// The dev server is the app's origin only in a dev build (no +/// `custom-protocol`); otherwise the app is served from `tauri://` and the dev +/// server's address is an ordinary page a user may be developing. +fn app_origins_from(config: &tauri::Config, dev: bool) -> Vec { + let mut own: Vec = if dev { + config.build.dev_url.iter().cloned().collect() + } else { + Vec::new() + }; + for window in &config.app.windows { + if let WebviewUrl::External(url) = &window.url { + own.push(url.clone()); + } + } + own +} + +/// A tab on any app origin would run with the app's IPC permissions, so none +/// may be browsed to, in any build or on any platform. +fn is_app_origin(app: &AppHandle, url: &Url) -> bool { + matches_app_origin(url, &app_origins(app)) +} + +fn matches_app_origin(url: &Url, own: &[Url]) -> bool { + is_internal_origin(url) || own.iter().any(|app| app.origin() == url.origin()) +} + +/// Origins Tauri serves bundled assets and IPC from: `tauri://localhost`, +/// `ipc://`/`asset:`, and `http(s)://{tauri,ipc,asset}.localhost` +/// (Windows/Android), including subdomains. +fn is_internal_origin(url: &Url) -> bool { + match url.scheme() { + "tauri" | "ipc" | "asset" => true, + _ => url.host_str().is_some_and(|host| { + ["tauri", "ipc", "asset"].iter().any(|name| { + let internal = format!("{name}.localhost"); + host == internal || host.ends_with(&format!(".{internal}")) + }) + }), + } +} + +/// Every navigation, including redirects and link clicks, passes through here. +fn allow_navigation(app: &AppHandle, url: &Url) -> bool { + navigation_allowed(url, &app_origins(app)) +} + +/// On macOS and Linux this also sees subframe navigations, with no frame to +/// tell them apart. So it admits the documents frames are built from, which +/// take their origin from a page already allowed: `about:blank` and +/// `about:srcdoc` with any query or fragment, and `blob:` URLs of a web origin +/// other than the app's. `data:` stays out: it would also open top-level. +fn navigation_allowed(url: &Url, own: &[Url]) -> bool { + match url.scheme() { + "about" => matches!(url.path(), "blank" | "srcdoc"), + "blob" => Url::parse(url.path()).is_ok_and(|inner| { + matches!(inner.scheme(), "http" | "https") && !matches_app_origin(&inner, own) + }), + _ => parse_url(url.as_str()).is_ok() && !matches_app_origin(url, own), + } +} + +/// Run in every frame before page scripts. Wry's navigation hook only sees +/// main-frame navigations on Windows (WebView2 `NavigationStarting`), so a +/// frame pointed at an app origin would load it; empty such frames instead. +fn frame_guard_script(own: &[Url]) -> String { + let origins: Vec = own + .iter() + .map(|url| url.origin().ascii_serialization()) + .collect(); + let origins = serde_json::to_string(&origins).unwrap_or_else(|_| "[]".into()); + format!( + r#"(function () {{ + if (window.top === window) return; + var own = {origins}; + var p = location.protocol, h = location.hostname; + var internal = p === "tauri:" || p === "ipc:" || p === "asset:" || + /(^|\.)(tauri|ipc|asset)\.localhost$/.test(h); + if (!internal && own.indexOf(location.origin) < 0) return; + try {{ window.stop(); }} catch (e) {{}} + // Runs before the document is parsed, so there may be no root yet. + try {{ location.replace("about:blank"); }} catch (e) {{}} + if (document.documentElement) document.documentElement.textContent = ""; +}})();"# + ) +} + +fn find(app: &AppHandle, id: &str) -> Result { + app.get_webview(&label(id)?) + .ok_or_else(|| "Browser tab is not open".to_string()) +} + +fn place(webview: &Webview, bounds: Bounds) -> Result<(), String> { + webview + .set_position(LogicalPosition::new(bounds.x, bounds.y)) + .map_err(|e| e.to_string())?; + webview + .set_size(LogicalSize::new( + bounds.width.max(1.0), + bounds.height.max(1.0), + )) + .map_err(|e| e.to_string()) +} + +/// Async so creation runs off the main thread: on Windows, building a child +/// webview from a synchronous command (which runs on the main thread) deadlocks. +#[tauri::command] +pub async fn browser_open( + app: AppHandle, + window: Window, + id: String, + url: String, + bounds: Bounds, + visible: bool, +) -> Result<(), String> { + let label = label(&id)?; + let _open = OPEN_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(webview) = app.get_webview(&label) { + place(&webview, bounds)?; + return if visible { + webview.show().map_err(|e| e.to_string()) + } else { + webview.hide().map_err(|e| e.to_string()) + }; + } + let url = parse_url(&url)?; + if is_app_origin(&app, &url) { + return Err("That address serves MonoCode itself".into()); + } + let owner = window.label().to_string(); + + let emit = { + let app = app.clone(); + let owner = owner.clone(); + move |event: BrowserEvent| { + let _ = app.emit_to(owner.as_str(), EVENT, event); + } + }; + let on_load = { + let emit = emit.clone(); + let id = id.clone(); + move |_: Webview, payload: tauri::webview::PageLoadPayload<'_>| { + emit(BrowserEvent::Load { + id: id.clone(), + url: payload.url().to_string(), + loading: matches!(payload.event(), PageLoadEvent::Started), + }); + } + }; + let on_title = { + let emit = emit.clone(); + let id = id.clone(); + move |_: Webview, title: String| { + emit(BrowserEvent::Title { + id: id.clone(), + title, + }); + } + }; + let on_new_window = { + let emit = emit.clone(); + let id = id.clone(); + move |url: Url, _| { + if parse_url(url.as_str()).is_ok() { + emit(BrowserEvent::OpenTab { + id: id.clone(), + url: url.to_string(), + }); + } + NewWindowResponse::Deny + } + }; + let on_navigation = { + let app = app.clone(); + move |url: &Url| allow_navigation(&app, url) + }; + + let builder = WebviewBuilder::new(&label, WebviewUrl::External(url)) + .on_page_load(on_load) + .on_document_title_changed(on_title) + .on_new_window(on_new_window) + .on_navigation(on_navigation) + .initialization_script_for_all_frames(frame_guard_script(&app_origins(&app))) + .initialization_script(CONSOLE_CAPTURE); + let webview = window + .add_child( + builder, + LogicalPosition::new(bounds.x, bounds.y), + LogicalSize::new(bounds.width.max(1.0), bounds.height.max(1.0)), + ) + .map_err(|e| e.to_string())?; + if !visible { + webview.hide().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// The page's current URL. Same-document navigation (`pushState`, hash +/// changes) emits no load event, so the page is asked directly. +#[tauri::command] +pub async fn browser_url(app: AppHandle, id: String) -> Result { + let webview = find(&app, &id)?; + tauri::async_runtime::spawn_blocking(move || { + eval_string(&webview, "location.href".into(), Duration::from_secs(2)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub fn browser_set_bounds(app: AppHandle, id: String, bounds: Bounds) -> Result<(), String> { + place(&find(&app, &id)?, bounds) +} + +#[tauri::command] +pub fn browser_set_visible(app: AppHandle, id: String, visible: bool) -> Result<(), String> { + let webview = find(&app, &id)?; + if visible { + webview.show() + } else { + webview.hide() + } + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn browser_focus(app: AppHandle, id: String) -> Result<(), String> { + find(&app, &id)?.set_focus().map_err(|e| e.to_string()) +} + +/// How long a fragment target waits for the page to say whether it stays in the +/// document. A page that does not answer (hung, still blank) cannot apply a +/// fragment either, so it is sent to the webview like any other target. +const FRAGMENT_PROBE: Duration = Duration::from_secs(1); + +/// The page refuses to start a probe this much before the native side gives up +/// on it, so a probe that was submitted but ran late does not begin changing +/// the page after the target was handed to the webview instead. It cannot stop +/// a probe that started in time from finishing late: the page's synchronous +/// handlers run inside it for as long as they take. +const PROBE_MARGIN: Duration = Duration::from_millis(200); + +/// Runs in the page. When `target` differs from the page's URL only by its +/// fragment, the page applies it itself and reports what its URL is afterwards. +/// `location.href` changes synchronously only for same-document navigations +/// (fragments, `pushState`, `replaceState`); a document load changes it when it +/// commits, later. So: +/// +/// - "same:": the URL changed, so the page stayed in its document, at +/// ``: the target, or wherever its handlers rewrote it to. +/// - "unchanged": the URL did not change. A handler refused the navigation, +/// or replaced it with a document load that has not committed yet. +/// - "unknown": the assignment threw; the page may have done anything. +/// - "other": the target is a different document; nothing was changed. +/// - "expired": the probe started after `deadline` (ms since the epoch) and +/// did nothing. +/// +/// Both sides are normalized by the page, so the comparison cannot be fooled by +/// how the target was spelled. The page's own scripts can replace `Date`, `URL` +/// or `location` behavior, and handlers that react later change the URL +/// unseen; the answer is only as good as the page's built-ins. +fn fragment_probe(target: &Url, deadline_ms: u128) -> String { + let target = serde_json::to_string(target.as_str()).unwrap_or_default(); + format!( + r##"(function () {{ + var assigned = false; + try {{ + if (Date.now() > {deadline_ms}) return "expired"; + var to = new URL({target}), from = new URL(location.href); + var toBase = new URL(to.href), fromBase = new URL(from.href); + toBase.hash = ""; fromBase.hash = ""; + if (to.href.indexOf("#") < 0 || toBase.href !== fromBase.href) return "other"; + assigned = true; + location.href = {target}; + var now = new URL(location.href).href; + return now === from.href && now !== to.href ? "unchanged" : "same:" + now; + }} catch (e) {{ + return assigned ? "unknown" : "other"; + }} +}})()"## + ) +} + +/// What the native side knows about a navigation it was asked to make. +#[derive(Serialize, Debug, PartialEq, Eq, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum Outcome { + /// The page stayed in its document: no load follows, and `url` is where + /// the page's synchronous handlers left it. + Applied, + /// The webview was told to load a document. Load events report what + /// happens next, but a load that fails before it commits or is denied may + /// report none. + Document, + /// It is not known whether a load follows: the page refused the target or + /// replaced it with a load not yet committed, or the native navigation of a + /// fragment target may land in the same document without any event. + Unknown, +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +pub struct Navigation { + outcome: Outcome, + /// The page's URL right after an applied navigation. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, +} + +impl Navigation { + fn of(outcome: Outcome) -> Self { + Navigation { outcome, url: None } + } +} + +/// What to do with the page's answer to a probe. +#[derive(Debug, PartialEq, Eq)] +enum Probed { + /// The page's answer is the outcome; the webview is not navigated. + Settled(Navigation), + /// The target is another document: navigate the webview. + Document, + /// No usable answer (none in time, expired, unexpected): navigate the + /// webview without knowing whether the page stays in its document. + Unanswered, +} + +fn judge_probe(answer: &Result) -> Probed { + match answer.as_deref() { + Ok(answer) => { + if let Some(url) = answer.strip_prefix("same:") { + return Probed::Settled(Navigation { + outcome: Outcome::Applied, + url: Some(url.to_string()), + }); + } + match answer { + // The page already acted (or refused); navigating natively + // would be a second action. + "unchanged" | "unknown" => Probed::Settled(Navigation::of(Outcome::Unknown)), + "other" => Probed::Document, + _ => Probed::Unanswered, + } + } + Err(_) => Probed::Unanswered, + } +} + +fn now_ms() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default() +} + +/// Point the tab at `url`. A target with a fragment is first offered to the +/// page, because the webview's navigation hook is not told about same-document +/// navigations on every platform and reports only a URL, never which request or +/// frame it belongs to; the page's own answer is the one completion that is +/// known. Everything else, including an unanswered probe, is handed to the +/// webview. +/// +/// The frontend sends one navigation per tab at a time, so they are dispatched +/// in the order they were made. That orders dispatch only: it does not order +/// every eventual effect on the page, such as a load that starts after the next +/// navigation was dispatched. +#[tauri::command] +pub async fn browser_navigate( + app: AppHandle, + id: String, + url: String, +) -> Result { + let url = parse_url(&url)?; + if is_app_origin(&app, &url) { + return Err("That address serves MonoCode itself".into()); + } + let webview = find(&app, &id)?; + let mut outcome = Outcome::Document; + if url.fragment().is_some() { + let deadline = now_ms() + (FRAGMENT_PROBE - PROBE_MARGIN).as_millis(); + let probe = fragment_probe(&url, deadline); + let page = webview.clone(); + let answer = + tauri::async_runtime::spawn_blocking(move || eval_string(&page, probe, FRAGMENT_PROBE)) + .await + .map_err(|e| e.to_string())?; + match judge_probe(&answer) { + Probed::Settled(navigation) => return Ok(navigation), + Probed::Document => {} + // Whether the target stays in the current document is not known. + // The webview's own URL cannot settle it: `Webview::url` panics on + // macOS while a hung or blank page has no URL yet. + Probed::Unanswered => outcome = Outcome::Unknown, + } + } + webview.navigate(url).map_err(|e| e.to_string())?; + Ok(Navigation::of(outcome)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HistoryAction { + Back, + Forward, + Reload, + Stop, +} + +#[tauri::command] +pub fn browser_history(app: AppHandle, id: String, action: HistoryAction) -> Result<(), String> { + let webview = find(&app, &id)?; + match action { + HistoryAction::Reload => webview.reload(), + HistoryAction::Back => webview.eval("history.back()"), + HistoryAction::Forward => webview.eval("history.forward()"), + HistoryAction::Stop => webview.eval("window.stop()"), + } + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn browser_close(app: AppHandle, id: String) -> Result<(), String> { + match app.get_webview(&label(&id)?) { + Some(webview) => webview.close().map_err(|e| e.to_string()), + None => Ok(()), + } +} + +/// Evaluate `js` and hand back its JSON-encoded result. Native evaluation +/// cannot return promises or arbitrary objects, so callers always produce a +/// string; wry encodes that string as JSON once more. +fn eval_string(webview: &Webview, js: String, timeout: Duration) -> Result { + let (tx, rx) = mpsc::channel(); + webview + .eval_with_callback(js, move |raw| { + let _ = tx.send(raw); + }) + .map_err(|e| e.to_string())?; + let raw = rx + .recv_timeout(timeout) + .map_err(|_| "The page did not respond".to_string())?; + Ok(serde_json::from_str::(&raw).unwrap_or_default()) +} + +/// Wrap an agent script as the body of an async function. With `expression` +/// the script is returned as one expression (`document.title`, +/// `[1,2].map(x => { return x * 2; })`); otherwise it runs as a function body +/// (statements, an explicit `return`). Whether a script is an expression is for +/// the page's JavaScript parser to say, so `run_agent_script` tries the +/// expression form first and falls back when the page cannot compile it. +fn agent_script(key: &str, script: &str, expression: bool) -> String { + let body = if expression { + let expr = script + .trim() + .trim_end_matches(|c: char| c == ';' || c.is_whitespace()); + format!("return (\n{expr}\n);") + } else { + script.to_string() + }; + let key = serde_json::to_string(key).unwrap_or_default(); + format!( + r#"(function () {{ + var store = window.__monocodeResults || (window.__monocodeResults = {{}}); + var key = {key}; + function encode(value) {{ + var seen = new WeakSet(); + return JSON.stringify(value === undefined ? null : value, function (_, v) {{ + if (typeof Node !== "undefined" && v instanceof Node) {{ + var html = v.outerHTML || v.textContent || ""; + return html.length > 20000 ? html.slice(0, 20000) + "…" : html; + }} + if (typeof v === "function") return "[Function " + (v.name || "anonymous") + "]"; + if (typeof v === "bigint") return v.toString(); + if (v && typeof v === "object") {{ + if (seen.has(v)) return "[Circular]"; + seen.add(v); + }} + return v; + }}); + }} + var pending = {{ pending: true }}; + store[key] = pending; + function settle(entry) {{ + // Publish only while this run still owns the key; an abandoned run (entry + // deleted) or a reused key must not be overwritten by a late result. + if (store[key] === pending) store[key] = entry; + }} + (async function () {{ +{body} + }})().then( + function (value) {{ + try {{ settle({{ ok: true, json: encode(value) }}); }} + catch (e) {{ settle({{ ok: false, error: "Result is not serializable: " + e }}); }} + }}, + function (e) {{ settle({{ ok: false, error: String((e && e.stack) || e) }}); }} + ); + return "started"; +}})()"# + ) +} + +fn result_poll(key: &str) -> String { + let key = serde_json::to_string(key).unwrap_or_default(); + format!( + r#"(function () {{ + var store = window.__monocodeResults, key = {key}; + var entry = store && store[key]; + if (!entry) return "lost"; + if (entry.pending) return ""; + delete store[key]; + return JSON.stringify(entry); +}})()"# + ) +} + +/// Forget a run the caller stopped waiting for. The entry is deleted at once; +/// a run still going sees it is no longer the owner and drops its late result. +fn result_abandon(key: &str) -> String { + let key = serde_json::to_string(key).unwrap_or_default(); + format!( + r#"(function () {{ + var store = window.__monocodeResults, key = {key}; + if (store) delete store[key]; + return ""; +}})()"# + ) +} + +fn run_agent_script(webview: &Webview, script: &str, timeout: Duration) -> Result { + let key = uuid::Uuid::new_v4().simple().to_string(); + let result = run_agent_script_keyed(webview, &key, script, timeout); + if result.is_err() { + let _ = eval_string(webview, result_abandon(&key), Duration::from_secs(1)); + } + result +} + +fn run_agent_script_keyed( + webview: &Webview, + key: &str, + script: &str, + timeout: Duration, +) -> Result { + let key = key.to_string(); + let mut started = String::new(); + for expression in [true, false] { + started = eval_string( + webview, + agent_script(&key, script, expression), + Duration::from_secs(5), + )?; + if started == "started" { + break; + } + } + if started != "started" { + return Err("The script did not start. Check it for syntax errors.".into()); + } + let deadline = Instant::now() + timeout; + loop { + let reply = eval_string(webview, result_poll(&key), Duration::from_secs(5))?; + match reply.as_str() { + "" => {} + "lost" => return Err("The page navigated away before the script finished".into()), + entry => { + let entry: Value = + serde_json::from_str(entry).map_err(|_| "Unreadable script result")?; + if entry["ok"].as_bool() == Some(true) { + let json = entry["json"].as_str().unwrap_or("null"); + return Ok(serde_json::from_str(json).unwrap_or(Value::Null)); + } + return Err(entry["error"] + .as_str() + .unwrap_or("Script failed") + .to_string()); + } + } + if Instant::now() >= deadline { + return Err(format!( + "Script still running after {} ms", + timeout.as_millis() + )); + } + std::thread::sleep(EVAL_POLL); + } +} + +/// Run an agent's script in a tab and return its JSON result. +#[tauri::command] +pub async fn browser_eval( + app: AppHandle, + id: String, + script: String, + timeout_ms: Option, +) -> Result { + let webview = find(&app, &id)?; + let timeout = Duration::from_millis(timeout_ms.unwrap_or(10_000)).min(EVAL_TIMEOUT_MAX); + tauri::async_runtime::spawn_blocking(move || run_agent_script(&webview, &script, timeout)) + .await + .map_err(|e| e.to_string())? +} + +/// The visible part of a tab as base64 PNG. +#[tauri::command] +pub async fn browser_screenshot(app: AppHandle, id: String) -> Result { + let webview = find(&app, &id)?; + tauri::async_runtime::spawn_blocking(move || snapshot_png(&webview)) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(target_os = "macos")] +fn snapshot_png(webview: &Webview) -> Result { + use base64::Engine; + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage}; + use objc2_foundation::{NSDictionary, NSError}; + use objc2_web_kit::WKWebView; + + let (tx, rx) = mpsc::channel::, String>>(); + webview + .with_webview(move |platform| { + let view = platform.inner().cast::(); + if view.is_null() { + let _ = tx.send(Err("Browser view is gone".into())); + return; + } + // SAFETY: wry hands us its live WKWebView on the main thread. + let view = unsafe { &*view }; + let tx = tx.clone(); + let handler = block2::RcBlock::new(move |image: *mut NSImage, _: *mut NSError| { + // SAFETY: WebKit passes a valid image or null. + let png = unsafe { image.as_ref() } + .and_then(|image| image.TIFFRepresentation()) + .and_then(|tiff| NSBitmapImageRep::imageRepWithData(&tiff)) + .and_then(|rep| unsafe { + rep.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + }) + .map(|data| data.to_vec()) + .ok_or_else(|| "Could not capture the page".to_string()); + let _ = tx.send(png); + }); + unsafe { view.takeSnapshotWithConfiguration_completionHandler(None, &handler) }; + }) + .map_err(|e| e.to_string())?; + let png = rx + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "Timed out capturing the page".to_string())??; + Ok(base64::engine::general_purpose::STANDARD.encode(png)) +} + +#[cfg(not(target_os = "macos"))] +fn snapshot_png(_webview: &Webview) -> Result { + Err("Browser screenshots are only available on macOS for now".into()) +} + +/// Close this window's tabs that the page no longer knows about. A webview +/// reload keeps native children alive while the page forgets them. +#[tauri::command] +pub fn browser_retain(window: Window, ids: Vec) -> Result<(), String> { + let keep: Vec = ids.iter().filter_map(|id| label(id).ok()).collect(); + for webview in window.webviews() { + let name = webview.label(); + if name.starts_with(LABEL_PREFIX) && !keep.iter().any(|label| label == name) { + let _ = webview.close(); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fragment_probe_embeds_the_target_as_a_json_string() { + let target = Url::parse("https://a.test/p?q=\"x\"#frag\"").unwrap(); + let script = fragment_probe(&target, 123); + let quoted = serde_json::to_string(target.as_str()).unwrap(); + assert_eq!(script.matches("ed).count(), 2); + assert!(script.contains("location.href = ")); + assert!(script.contains("Date.now() > 123")); + } + + #[test] + fn probe_answers_map_to_outcomes() { + let ok = |s: &str| Ok::(s.to_string()); + assert_eq!( + judge_probe(&ok("same:https://a.test/#c")), + Probed::Settled(Navigation { + outcome: Outcome::Applied, + url: Some("https://a.test/#c".into()), + }) + ); + for unknown in ["unchanged", "unknown"] { + assert_eq!( + judge_probe(&ok(unknown)), + Probed::Settled(Navigation::of(Outcome::Unknown)) + ); + } + assert_eq!(judge_probe(&ok("other")), Probed::Document); + assert_eq!(judge_probe(&ok("expired")), Probed::Unanswered); + assert_eq!(judge_probe(&ok("")), Probed::Unanswered); + assert_eq!( + judge_probe(&Err("The page did not respond".into())), + Probed::Unanswered + ); + } + + #[test] + fn navigation_serializes_like_the_frontend_expects() { + let applied = Navigation { + outcome: Outcome::Applied, + url: Some("https://a.test/#b".into()), + }; + assert_eq!( + serde_json::to_value(&applied).unwrap(), + serde_json::json!({ "outcome": "applied", "url": "https://a.test/#b" }) + ); + assert_eq!( + serde_json::to_value(Navigation::of(Outcome::Document)).unwrap(), + serde_json::json!({ "outcome": "document" }) + ); + assert_eq!( + serde_json::to_value(Navigation::of(Outcome::Unknown)).unwrap(), + serde_json::json!({ "outcome": "unknown" }) + ); + } + + /// Run the generated probe in Node against a stub `location` whose setter + /// behaves like the page's handlers would. `None` when Node is unavailable. + fn run_probe(cases: &[(&str, &str, &str, u128)]) -> Option> { + let driver = r##" + const cases = JSON.parse(process.argv[1]); + const results = []; + for (const { start, handler, script } of cases) { + let href = start, assigned = 0; + globalThis.location = { + get href() { return href; }, + set href(to) { + assigned++; + const next = new URL(to, href).href; + if (handler === "apply") href = next; + else if (handler === "rewrite-fragment") href = new URL("#c", href).href; + else if (handler === "rewrite-path") href = new URL("/other#x", href).href; + else if (handler === "throw") { href = next; throw new Error("handler"); } + // "refuse": the URL stays; a document load would commit later. + }, + }; + results.push({ answer: (0, eval)(script), href, assigned }); + } + console.log(JSON.stringify(results)); + "##; + let payload: Vec = cases + .iter() + .map(|(start, target, handler, deadline)| { + serde_json::json!({ + "start": start, + "handler": handler, + "script": fragment_probe(&Url::parse(target).unwrap(), *deadline), + }) + }) + .collect(); + let out = run_node(driver, &serde_json::to_string(&payload).unwrap())?; + Some(serde_json::from_slice(&out.stdout).unwrap()) + } + + #[test] + fn fragment_probe_executes() { + let future = now_ms() + 60_000; + let Some(results) = run_probe(&[ + ("https://a.test/p", "https://a.test/p#b", "apply", future), + ("https://a.test/p", "https://a.test/p#b", "refuse", future), + ( + "https://a.test/p", + "https://a.test/p#b", + "rewrite-fragment", + future, + ), + ( + "https://a.test/p", + "https://a.test/p#b", + "rewrite-path", + future, + ), + ("https://a.test/p", "https://a.test/p#b", "throw", future), + ("https://a.test/p", "https://b.test/p#b", "apply", future), + ("https://a.test/p#b", "https://a.test/p#b", "refuse", future), + ("https://a.test/p", "https://a.test/p#", "apply", future), + ("https://a.test/p", "https://a.test/p#b", "apply", 0), + ]) else { + return; + }; + let got: Vec<(String, u64)> = results + .iter() + .map(|r| { + ( + r["answer"].as_str().unwrap().to_string(), + r["assigned"].as_u64().unwrap(), + ) + }) + .collect(); + let want = [ + ("same:https://a.test/p#b", 1), + ("unchanged", 1), + ("same:https://a.test/p#c", 1), + ("same:https://a.test/other#x", 1), + ("unknown", 1), + ("other", 0), + // Already at the target: nothing to refuse. + ("same:https://a.test/p#b", 1), + ("same:https://a.test/p#", 1), + // A probe that starts late does not touch the page. + ("expired", 0), + ]; + let want: Vec<(String, u64)> = want.iter().map(|(a, n)| (a.to_string(), *n)).collect(); + assert_eq!(got, want); + } + + #[test] + fn only_targets_with_a_fragment_are_probed() { + let has = |s: &str| Url::parse(s).unwrap().fragment().is_some(); + assert!(has("https://a.test/#")); + assert!(has("https://a.test/p#x")); + assert!(!has("https://a.test/p")); + } + + #[test] + fn labels_reject_anything_but_plain_ids() { + assert_eq!(label("abc-1_2").unwrap(), "browser-abc-1_2"); + assert!(label("").is_err()); + assert!(label("../main").is_err()); + assert!(label(&"x".repeat(65)).is_err()); + } + + #[test] + fn agent_scripts_wrap_expressions_and_bodies() { + let expr = agent_script("k1", "document.title;\n", true); + assert!(expr.contains("return (\ndocument.title\n);")); + assert!(expr.contains("var key = \"k1\";")); + let call = agent_script("k1", "[1,2].map(x => { return x*2; })", true); + assert!(call.contains("return (\n[1,2].map(x => { return x*2; })\n);")); + let body = agent_script("k2", "const a = 1;\nreturn a;", false); + assert!(body.contains("const a = 1;\nreturn a;")); + assert!(!body.contains("return (\nconst")); + assert!(result_poll("k\"x").contains(r#"key = "k\"x""#)); + } + + fn run_node(driver: &str, args: &str) -> Option { + match std::process::Command::new("node") + .args(["-e", driver, args]) + .output() + { + Ok(out) => { + assert!( + out.status.success(), + "Node execution failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + Some(out) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + assert!(std::env::var_os("CI").is_none(), "Node is required in CI"); + eprintln!("Skipping Node execution test: node is unavailable ({error})"); + None + } + Err(error) => panic!("Failed to run Node: {error}"), + } + } + + /// Run the wrapped script in Node the way `run_agent_script` runs it in a + /// page: expression form first, body form when that will not compile. + /// `None` when Node is unavailable. + fn execute(script: &str) -> Option { + let driver = r#" + globalThis.window = globalThis; + const [first, second, poll] = JSON.parse(process.argv[1]); + let started = ""; + for (const wrapper of [first, second]) { + try { started = (0, eval)(wrapper); } catch (e) { started = ""; } + if (started === "started") break; + } + setTimeout(() => console.log(started === "started" ? (0, eval)(poll) : "not started"), 50); + "#; + let args = serde_json::to_string(&[ + agent_script("k", script, true), + agent_script("k", script, false), + result_poll("k"), + ]) + .unwrap(); + let out = run_node(driver, &args)?; + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Some(match serde_json::from_str::(&line) { + Ok(entry) => entry, + Err(_) => Value::String(line), + }) + } + + #[test] + fn expressions_and_bodies_execute() { + let result = |script: &str| execute(script).map(|v| v["json"].clone()); + if execute("1").is_none() { + return; + } + let ok = |json: &str| Some(Value::String(json.to_string())); + assert_eq!(result("1 + 2;\n"), ok("3")); + assert_eq!(result("[1,2].map(x => { return x * 2; })"), ok("[2,4]")); + assert_eq!(result("const a = 2;\nreturn a * 3;"), ok("6")); + assert_eq!(result("return 'return'"), ok("\"return\"")); + assert_eq!(result("await Promise.resolve(7)"), ok("7")); + } + + #[test] + fn runtime_errors_report_once_and_do_not_rerun() { + if execute("1").is_none() { + return; + } + // An expression that throws must not fall through to the body form + // and run its side effects a second time. + let entry = execute( + "(globalThis.n = (globalThis.n || 0) + 1, (() => { throw new Error('boom'); })())", + ) + .unwrap(); + assert_eq!(entry["ok"], Value::Bool(false)); + assert!(entry["error"].as_str().unwrap().contains("boom")); + let count = + execute("globalThis.n = (globalThis.n || 0) + 1; return globalThis.n;").unwrap(); + assert_eq!(count["json"], Value::String("1".into())); + let broken = execute("let = = ;").unwrap(); + assert_eq!(broken, Value::String("not started".into())); + } + + #[test] + fn app_and_internal_origins_never_browse() { + let own = [Url::parse("tauri://localhost/index.html").unwrap()]; + let dev = [Url::parse("http://localhost:1420").unwrap()]; + for blocked in [ + "tauri://localhost/", + "http://tauri.localhost/", + "https://tauri.localhost/x", + "https://a.tauri.localhost/", + "http://asset.localhost/f", + "https://asset.localhost/f", + "http://ipc.localhost/cmd", + "https://ipc.localhost/cmd", + "ipc://localhost/cmd", + "asset://localhost/f", + ] { + assert!( + matches_app_origin(&Url::parse(blocked).unwrap(), &own), + "{blocked}" + ); + } + assert!(matches_app_origin( + &Url::parse("http://localhost:1420/a").unwrap(), + &dev + )); + assert!(!matches_app_origin( + &Url::parse("http://localhost:3000").unwrap(), + &dev + )); + assert!(!matches_app_origin( + &Url::parse("https://example.com").unwrap(), + &own + )); + } + + #[test] + fn dev_server_is_an_app_origin_only_in_dev_builds() { + let config: tauri::Config = serde_json::from_value(serde_json::json!({ + "identifier": "test.monocode", + "build": { "devUrl": "http://localhost:1420" }, + "app": { "windows": [{ "url": "https://app.example.com/" }] }, + })) + .unwrap(); + let dev_server = Url::parse("http://localhost:1420/").unwrap(); + let external = Url::parse("https://app.example.com/x").unwrap(); + let dev = app_origins_from(&config, true); + assert!(matches_app_origin(&dev_server, &dev)); + assert!(matches_app_origin(&external, &dev)); + let bundled = app_origins_from(&config, false); + assert!(!matches_app_origin(&dev_server, &bundled)); + assert!(matches_app_origin(&external, &bundled)); + } + + #[test] + fn frame_guard_lists_app_origins() { + let script = frame_guard_script(&[Url::parse("http://localhost:1420/a").unwrap()]); + assert!(script.contains(r#"["http://localhost:1420"]"#)); + assert!(script.contains("window.top === window")); + assert!(frame_guard_script(&[]).contains("var own = [];")); + } + + #[test] + fn frame_guard_blanks_only_app_subframes() { + let script = frame_guard_script(&[Url::parse("http://localhost:1420/a").unwrap()]); + let driver = r#" + const script = JSON.parse(process.argv[1]); + const urls = [ + "tauri://localhost/index.html", "http://ipc.localhost/cmd", + "https://asset.localhost/file", "http://localhost:1420/a", + "https://example.com/", "http://localhost:3000/" + ]; + const results = []; + for (const top of [false, true]) { + for (const url of urls) { + let stopped = false; + globalThis.window = { stop() { stopped = true; } }; + window.top = top ? window : {}; + globalThis.location = new URL(url); + globalThis.document = { documentElement: { textContent: "page" } }; + let replaced = null; + location.replace = (to) => { replaced = to; }; + (0, eval)(script); + const blank = document.documentElement.textContent === ""; + // Document start: no root yet; must not throw or skip the reload. + globalThis.document = { documentElement: null }; + replaced = null; + let threw = false; + try { (0, eval)(script); } catch (e) { threw = true; } + results.push({ stopped, blank, early: !threw && replaced === "about:blank" }); + } + } + console.log(JSON.stringify(results)); + "#; + let Some(out) = run_node(driver, &serde_json::to_string(&script).unwrap()) else { + return; + }; + let results: Vec = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(results.len(), 12); + for (index, result) in results.iter().enumerate() { + let blocked = index < 4; + assert_eq!(result["stopped"], Value::Bool(blocked), "case {index}"); + assert_eq!(result["blank"], Value::Bool(blocked), "case {index}"); + assert_eq!(result["early"], Value::Bool(blocked), "case {index}"); + } + } + + #[test] + fn navigations_admit_web_pages_and_frame_documents_only() { + let own = [Url::parse("http://localhost:1420/").unwrap()]; + let allowed = |s: &str| navigation_allowed(&Url::parse(s).unwrap(), &own); + for ok in [ + "https://example.com/a", + "http://localhost:3000/", + "about:blank", + "about:blank#frame", + "about:blank?x=1", + "about:srcdoc", + "about:srcdoc#top", + "blob:https://example.com/6f1c-uuid", + ] { + assert!(allowed(ok), "{ok}"); + } + for blocked in [ + "file:///etc/passwd", + "javascript:1", + "data:text/html,x", + "about:config", + "blob:null/6f1c-uuid", + "blob:file:///6f1c-uuid", + "blob:http://localhost:1420/6f1c-uuid", + "blob:tauri://localhost/6f1c-uuid", + "http://localhost:1420/", + "tauri://localhost/", + ] { + assert!(!allowed(blocked), "{blocked}"); + } + // Typed and agent URLs stay limited to web pages. + assert!(parse_url("blob:https://a/b").is_err()); + assert!(parse_url("about:srcdoc").is_err()); + } + + #[test] + fn only_web_urls_open() { + assert!(parse_url("https://example.com").is_ok()); + assert!(parse_url("http://localhost:3000/a?b").is_ok()); + assert!(parse_url("about:blank").is_ok()); + assert!(parse_url("file:///etc/passwd").is_err()); + assert!(parse_url("javascript:alert(1)").is_err()); + assert!(parse_url("tauri://localhost").is_err()); + assert!(parse_url("not a url").is_err()); + } +} diff --git a/src-tauri/src/browser_console.js b/src-tauri/src/browser_console.js new file mode 100644 index 0000000000..6d8ce00c73 --- /dev/null +++ b/src-tauri/src/browser_console.js @@ -0,0 +1,42 @@ +// Injected into every built-in browser page before its own scripts. Keeps the +// last console messages and uncaught errors so agents can read them later. +(function () { + if (window.__monocodeConsole) return; + var MAX = 500; + var entries = []; + Object.defineProperty(window, "__monocodeConsole", { + value: entries, + configurable: false, + enumerable: false, + }); + function describe(value) { + if (typeof value === "string") return value; + if (value instanceof Error) return value.stack || String(value); + try { + var text = JSON.stringify(value); + return text === undefined ? String(value) : text; + } catch (_) { + return String(value); + } + } + function record(level, args) { + var text = Array.prototype.map.call(args, describe).join(" "); + if (text.length > 4000) text = text.slice(0, 4000) + "…"; + entries.push({ level: level, text: text, time: Date.now(), url: location.href }); + if (entries.length > MAX) entries.splice(0, entries.length - MAX); + } + ["log", "info", "warn", "error", "debug"].forEach(function (level) { + var original = console[level]; + console[level] = function () { + record(level, arguments); + return original.apply(this, arguments); + }; + }); + window.addEventListener("error", function (event) { + var where = event.filename ? " (" + event.filename + ":" + event.lineno + ")" : ""; + record("uncaught", [(event.error && event.error.stack) || event.message + where]); + }); + window.addEventListener("unhandledrejection", function (event) { + record("unhandledrejection", [event.reason]); + }); +})(); diff --git a/src-tauri/src/browser_mcp.rs b/src-tauri/src/browser_mcp.rs new file mode 100644 index 0000000000..9806dd24a8 --- /dev/null +++ b/src-tauri/src/browser_mcp.rs @@ -0,0 +1,362 @@ +//! `monocode browser-mcp`: a stdio MCP server that lets an agent drive the +//! MonoCode built-in browser. It owns no state; every tool call is forwarded +//! over the authenticated loopback control channel (namespace `browser`) to +//! the window running the agent's session, which only answers during that +//! session's live turn. +use std::io::{BufRead, Write}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +const PROTOCOL_VERSION: &str = "2025-06-18"; +const SUPPORTED_VERSIONS: &[&str] = &["2024-11-05", "2025-03-26", "2025-06-18"]; + +const INSTRUCTIONS: &str = "This chat's web browser: the embedded, built-in, in-app browser in \ +MonoCode, shown in the browser panel beside the chat. Requests to open a page in the embedded \ +browser, evaluate JavaScript in it, or inspect its page refer to these tools; users do not need \ +to name the server or tools. Honor requests for a different browser or browsing method. Prefer it over fetching pages another way whenever \ +the result depends on a real rendered page: how something looks, JavaScript, console output, \ +clicking or typing, signed-in pages, and local dev servers (localhost) you are building. \ +Its tabs belong to this chat only and run in the background: the user can open the panel to \ +watch or take over, and may already be using a tab, so prefer opening a new tab over navigating \ +one you did not open. Sign-ins are shared with the user's other MonoCode browser tabs. Most \ +tools act on the active tab unless you pass tabId. Use browser_read to see the page, \ +browser_screenshot to see how it looks, browser_console for logs and errors, browser_eval for \ +anything else."; + +fn tab_id() -> Value { + json!({"type": "string", "description": "Tab to act on. Defaults to the active tab."}) +} + +fn tools() -> Value { + json!([ + { + "name": "browser_tabs", + "description": "List this chat's browser tabs with their id, URL, title, and which one is active.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "browser_open", + "description": "Open a web page in the embedded browser (a new tab of this chat's in-app browser) and wait for it to load. Use this to view, test, or interact with a site or a local dev server. It does not open the panel. Returns the new tab id.", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "http(s) URL, or a bare host such as localhost:3000."} + }, + "required": ["url"] + } + }, + { + "name": "browser_navigate", + "description": "Load a URL in an existing tab and wait for it to load.", + "inputSchema": { + "type": "object", + "properties": {"url": {"type": "string"}, "tabId": tab_id()}, + "required": ["url"] + } + }, + { + "name": "browser_history", + "description": "Go back, go forward, or reload a tab.", + "inputSchema": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["back", "forward", "reload"]}, + "tabId": tab_id() + }, + "required": ["action"] + } + }, + { + "name": "browser_select", + "description": "Make a tab the active one.", + "inputSchema": { + "type": "object", + "properties": {"tabId": {"type": "string"}}, + "required": ["tabId"] + } + }, + { + "name": "browser_close", + "description": "Close a tab.", + "inputSchema": { + "type": "object", + "properties": {"tabId": {"type": "string"}}, + "required": ["tabId"] + } + }, + { + "name": "browser_read", + "description": "Read the page: visible text (default) or HTML, for the whole document or the first element matching a CSS selector. Long output is truncated.", + "inputSchema": { + "type": "object", + "properties": { + "format": {"type": "string", "enum": ["text", "html"]}, + "selector": {"type": "string", "description": "CSS selector to read instead of the whole page."}, + "maxChars": {"type": "integer", "minimum": 100, "maximum": 200000}, + "tabId": tab_id() + } + } + }, + { + "name": "browser_eval", + "description": "Evaluate JavaScript in a page in the embedded browser and return the result as JSON. The script is the body of an async function: use `return` to send a value back and `await` freely. A single expression without `return` is also accepted. DOM nodes come back as their outer HTML.", + "inputSchema": { + "type": "object", + "properties": { + "script": {"type": "string"}, + "timeoutMs": {"type": "integer", "minimum": 100, "maximum": 25000}, + "tabId": tab_id() + }, + "required": ["script"] + } + }, + { + "name": "browser_console", + "description": "Console messages, uncaught errors, and unhandled rejections the page logged since it loaded (most recent last).", + "inputSchema": { + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 500}, + "clear": {"type": "boolean", "description": "Clear the buffer after reading."}, + "tabId": tab_id() + } + } + }, + { + "name": "browser_click", + "description": "Click the first element matching a CSS selector, or the first link, button, or control whose text contains `text`.", + "inputSchema": { + "type": "object", + "properties": { + "selector": {"type": "string"}, + "text": {"type": "string"}, + "tabId": tab_id() + } + } + }, + { + "name": "browser_type", + "description": "Type into an input, textarea, select, or contenteditable element matching a CSS selector. Fires input and change events.", + "inputSchema": { + "type": "object", + "properties": { + "selector": {"type": "string"}, + "text": {"type": "string"}, + "clear": {"type": "boolean", "description": "Replace the current value (default true)."}, + "submit": {"type": "boolean", "description": "Submit the element's form afterwards."}, + "tabId": tab_id() + }, + "required": ["selector", "text"] + } + }, + { + "name": "browser_wait", + "description": "Wait until an element matching a CSS selector exists, or the page text contains `text`.", + "inputSchema": { + "type": "object", + "properties": { + "selector": {"type": "string"}, + "text": {"type": "string"}, + "timeoutMs": {"type": "integer", "minimum": 100, "maximum": 25000}, + "tabId": tab_id() + } + } + }, + { + "name": "browser_screenshot", + "description": "See what a page in the embedded browser looks like: captures the tab's viewport as a PNG image, whether or not the browser panel is open.", + "inputSchema": {"type": "object", "properties": {"tabId": tab_id()}} + } + ]) +} + +fn is_tool(name: &str) -> bool { + tools() + .as_array() + .is_some_and(|list| list.iter().any(|tool| tool["name"] == name)) +} + +/// Turn an executor reply into MCP tool content. +fn tool_result(reply: Result) -> Value { + let error = + |message: String| json!({"content": [{"type": "text", "text": message}], "isError": true}); + let value = match reply { + Ok(value) => value, + Err(message) => return error(message), + }; + if value.get("ok").and_then(Value::as_bool) != Some(true) { + let message = value + .get("error") + .and_then(Value::as_str) + .unwrap_or("MonoCode browser request failed") + .to_string(); + return error(message); + } + let result = value.get("result").cloned().unwrap_or(Value::Null); + if let Some(data) = result.get("image").and_then(Value::as_str) { + let mut content = vec![json!({ + "type": "image", + "data": data, + "mimeType": result.get("mimeType").and_then(Value::as_str).unwrap_or("image/png"), + })]; + if let Some(text) = result.get("text").and_then(Value::as_str) { + content.push(json!({"type": "text", "text": text})); + } + return json!({"content": content}); + } + let text = match &result { + Value::String(text) => text.clone(), + other => serde_json::to_string_pretty(other).unwrap_or_default(), + }; + json!({"content": [{"type": "text", "text": text}]}) +} + +fn call_tool(params: &Value) -> Value { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + if !is_tool(name) { + return tool_result(Err(format!("Unknown tool: {name}"))); + } + let input = match params.get("arguments") { + Some(Value::Object(map)) => Value::Object(map.clone()), + None | Some(Value::Null) => json!({}), + Some(_) => return tool_result(Err("Tool arguments must be an object".into())), + }; + let request_id = uuid::Uuid::new_v4().to_string(); + tool_result( + crate::control_cli::send("browser", name, &input, &request_id) + .map_err(|failure| failure.error), + ) +} + +/// Answer one JSON-RPC message, or None for notifications. +fn handle(message: &Value) -> Option { + let id = message.get("id").cloned()?; + let method = message.get("method").and_then(Value::as_str).unwrap_or(""); + let params = message.get("params").cloned().unwrap_or(Value::Null); + let result = match method { + "initialize" => { + let requested = params + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION); + let version = if SUPPORTED_VERSIONS.contains(&requested) { + requested + } else { + PROTOCOL_VERSION + }; + json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "monocode-browser", "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + }) + } + "ping" => json!({}), + "tools/list" => json!({"tools": tools()}), + "tools/call" => call_tool(¶ms), + _ => { + return Some(json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": format!("Method not found: {method}")}, + })) + } + }; + Some(json!({"jsonrpc": "2.0", "id": id, "result": result})) +} + +pub fn run() -> i32 { + let stdout = Arc::new(Mutex::new(std::io::stdout())); + let write = |out: &Arc>, value: Value| { + if let Ok(mut out) = out.lock() { + let _ = writeln!(out, "{value}"); + let _ = out.flush(); + } + }; + let mut calls = Vec::new(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let Ok(message) = serde_json::from_str::(&line) else { + write( + &stdout, + json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}), + ); + continue; + }; + // Tool calls can wait on page loads; keep answering pings meanwhile. + if message.get("method").and_then(Value::as_str) == Some("tools/call") { + let out = stdout.clone(); + calls.push(std::thread::spawn(move || { + if let Some(reply) = handle(&message) { + write(&out, reply); + } + })); + calls.retain(|call| !call.is_finished()); + continue; + } + if let Some(reply) = handle(&message) { + write(&stdout, reply); + } + } + // The client closed stdin; still answer the calls it already sent. + for call in calls { + let _ = call.join(); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn negotiates_a_supported_protocol() { + let reply = handle(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}})).unwrap(); + assert_eq!(reply["result"]["protocolVersion"], "2025-03-26"); + let reply = handle(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}})).unwrap(); + assert_eq!(reply["result"]["protocolVersion"], PROTOCOL_VERSION); + } + + #[test] + fn lists_tools_and_ignores_notifications() { + let reply = handle(&json!({"jsonrpc":"2.0","id":"a","method":"tools/list"})).unwrap(); + let names: Vec<&str> = reply["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"browser_eval")); + assert!(names.iter().all(|name| name.starts_with("browser_"))); + assert!(handle(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})).is_none()); + let unknown = handle(&json!({"jsonrpc":"2.0","id":2,"method":"resources/list"})).unwrap(); + assert_eq!(unknown["error"]["code"], -32601); + } + + #[test] + fn maps_replies_to_tool_content() { + let text = tool_result(Ok(json!({"ok": true, "result": {"title": "A"}}))); + assert!(text["content"][0]["text"] + .as_str() + .unwrap() + .contains("\"title\"")); + let image = tool_result(Ok( + json!({"ok": true, "result": {"image": "AAAA", "mimeType": "image/png"}}), + )); + assert_eq!(image["content"][0]["type"], "image"); + let failed = tool_result(Ok(json!({"ok": false, "error": "nope"}))); + assert_eq!(failed["isError"], true); + assert_eq!(failed["content"][0]["text"], "nope"); + assert_eq!(tool_result(Err("down".into()))["isError"], true); + } + + #[test] + fn rejects_unknown_tools_without_calling_the_app() { + let reply = call_tool(&json!({"name": "shell_exec", "arguments": {}})); + assert_eq!(reply["isError"], true); + } +} diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 8a5b48a3e2..734242acc0 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -10,8 +10,10 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; +use tauri::{AppHandle, Emitter, Manager, State, Window}; +const BROWSER_TURN_INACTIVE: &str = + "The MonoCode browser only answers during this session's active turn."; const APP_TURN_INACTIVE: &str = "MonoCode app access is inactive. Use /operator once in this thread to enable it, then call the CLI during an active agent turn. Retrying this request now will not enable access."; #[derive(Clone)] @@ -161,11 +163,23 @@ struct Event { fn request_grant(host: &Inner, namespace: &str, token: &str) -> Result { let grant = match namespace { "control" => host.grants.values().find(|grant| grant.token == token), - "app" => host.app_grants.values().find(|grant| grant.token == token), + "app" | "browser" => host.app_grants.values().find(|grant| grant.token == token), _ => return Err("Unknown control namespace".into()), } .cloned() .ok_or("Connection revoked or unauthorized")?; + // Browser tools need no opt-in, only a live turn of that session in its + // own window: an idle or parked provider cannot drive the page. + if namespace == "browser" { + let live = host + .active + .get(&grant.session) + .is_some_and(|turn| turn.window == grant.window); + if !live || host.workers.contains_key(&grant.session) { + return Err(BROWSER_TURN_INACTIVE.into()); + } + return Ok(grant); + } if namespace == "app" && (host.grants.contains_key(&grant.session) || host.workers.contains_key(&grant.session) @@ -283,7 +297,7 @@ fn serve(mut stream: TcpStream, app: &AppHandle, inner: &Arc>) { #[tauri::command] pub fn control_enable( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, session_id: String, cwd: String, @@ -333,7 +347,7 @@ pub fn control_enable( #[tauri::command] pub fn control_disable( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, session_id: String, ) -> Result<(), String> { @@ -356,7 +370,7 @@ pub fn control_disable( #[tauri::command] pub fn control_attach_worker( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, lead_id: String, session_id: String, @@ -404,7 +418,7 @@ fn configure_worker_scratch(cmd: &mut Command, path: &Path) { #[tauri::command] pub fn control_authorize_turn( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, session_id: String, cwd: String, @@ -486,6 +500,47 @@ pub fn configure_child(app: &AppHandle, session_id: &str, cmd: &mut Command) { }; } +/// How a provider should launch the browser MCP server for this session. +/// The session credential travels in the environment, never in arguments. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserMcpLaunch { + command: String, + args: Vec, + env: HashMap, +} + +#[tauri::command] +pub fn control_browser_mcp( + window: Window, + host: State<'_, ControlHost>, + session_id: String, +) -> Result, String> { + let inner = host + .inner + .lock() + .map_err(|_| "Control service unavailable")?; + let Some(grant) = inner + .app_grants + .get(&session_id) + .filter(|grant| grant.window == window.label()) + else { + return Ok(None); + }; + let command = std::env::current_exe() + .map_err(|e| e.to_string())? + .to_string_lossy() + .into_owned(); + Ok(Some(BrowserMcpLaunch { + command, + args: vec!["browser-mcp".into()], + env: HashMap::from([ + ("MONOCODE_APP_ENDPOINT".into(), host.endpoint.clone()), + ("MONOCODE_APP_TOKEN".into(), grant.token.clone()), + ]), + })) +} + #[tauri::command] pub fn app_cli_path() -> Result { std::env::current_exe() @@ -495,7 +550,7 @@ pub fn app_cli_path() -> Result { #[tauri::command] pub fn control_reply( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, id: String, response: Value, @@ -656,6 +711,38 @@ mod tests { assert!(request_grant(&inner, "app", "app-token").is_err()); } #[test] + fn browser_access_needs_a_live_turn_in_the_same_window() { + let mut inner = Inner::default(); + inner.app_grants.insert( + "chat".into(), + Grant { + window: "main".into(), + session: "chat".into(), + cwd: "/repo".into(), + token: "app-token".into(), + }, + ); + assert!(matches!( + request_grant(&inner, "browser", "app-token"), + Err(error) if error == BROWSER_TURN_INACTIVE + )); + let turn = |window: &str| ActiveTurn { + window: window.into(), + cwd: "/repo".into(), + app_allowed: false, + }; + // No /operator opt-in needed, unlike the app namespace. + inner.active.insert("chat".into(), turn("main")); + assert!(request_grant(&inner, "browser", "app-token").is_ok()); + assert!(request_grant(&inner, "app", "app-token").is_err()); + inner.active.insert("chat".into(), turn("window-2")); + assert!(request_grant(&inner, "browser", "app-token").is_err()); + inner.active.insert("chat".into(), turn("main")); + inner.workers.insert("chat".into(), "lead".into()); + assert!(request_grant(&inner, "browser", "app-token").is_err()); + assert!(request_grant(&inner, "browser", "wrong").is_err()); + } + #[test] fn app_token_survives_normal_turns_but_only_works_when_opted_in() { let mut inner = Inner::default(); assert!(inner.prepare_app_grant("ordinary", "main", "/repo")); diff --git a/src-tauri/src/control_cli.rs b/src-tauri/src/control_cli.rs index f6bb3d2333..5b554fd8d9 100644 --- a/src-tauri/src/control_cli.rs +++ b/src-tauri/src/control_cli.rs @@ -329,7 +329,8 @@ fn run_mode(args: Vec, app_mode: bool) -> i32 { } Parsed::Call(action, input, request_id) => (action, input, request_id), }; - match send(&action, &input, &request_id, app_mode) { + let namespace = if app_mode { "app" } else { "control" }; + match send(namespace, &action, &input, &request_id) { Ok(mut value) => { if value.get("ok").and_then(Value::as_bool) == Some(true) { println!("{value}"); @@ -370,8 +371,8 @@ fn with_retry_hint(mut value: Value, request_id: &str) -> Value { value } -struct Failure { - error: String, +pub(crate) struct Failure { + pub(crate) error: String, /// The request was already on the wire, so the run may have applied it. sent: bool, } @@ -388,7 +389,14 @@ fn sent(error: impl Into) -> Failure { } } -fn send(action: &str, input: &Value, request_id: &str, app_mode: bool) -> Result { +pub(crate) fn send( + namespace: &str, + action: &str, + input: &Value, + request_id: &str, +) -> Result { + // The app and browser namespaces share the session's app credential. + let app_mode = namespace != "control"; let endpoint_key = if app_mode { "MONOCODE_APP_ENDPOINT" } else { @@ -429,11 +437,16 @@ fn send(action: &str, input: &Value, request_id: &str, app_mode: bool) -> Result writeln!( stream, "{}", - json!({"token":token,"action":action,"input":input,"requestId":request_id,"namespace":if app_mode { "app" } else { "control" }}) + json!({"token":token,"action":action,"input":input,"requestId":request_id,"namespace":namespace}) ) .map_err(|e| sent(e.to_string()))?; let mut line = String::new(); - let max_response: u64 = if app_mode { 4_000_000 } else { 2_000_000 }; + // Browser screenshots come back as base64 PNG. + let max_response: u64 = match namespace { + "browser" => 16_000_000, + "app" => 4_000_000, + _ => 2_000_000, + }; BufReader::new(stream) .take(max_response + 1) .read_line(&mut line) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67f12b2148..5ac044150e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod account_identity; mod artifacts; mod automations; mod azure_devops; +mod browser; +pub mod browser_mcp; mod chat_background; mod checkpoint; mod codex_mono_store; @@ -184,7 +186,7 @@ pub(crate) fn passwd_identity() -> Option { #[tauri::command] fn set_traffic_lights_visible( - #[allow(unused_variables)] window: tauri::WebviewWindow, + #[allow(unused_variables)] window: tauri::Window, #[allow(unused_variables)] visible: bool, ) { #[cfg(target_os = "macos")] @@ -193,7 +195,7 @@ fn set_traffic_lights_visible( #[tauri::command] fn set_window_background_blur( - #[allow(unused_variables)] window: tauri::WebviewWindow, + #[allow(unused_variables)] window: tauri::Window, #[allow(unused_variables)] radius: u8, ) { #[cfg(target_os = "macos")] @@ -202,7 +204,7 @@ fn set_window_background_blur( #[tauri::command] fn set_dock_badge( - #[allow(unused_variables)] window: tauri::WebviewWindow, + #[allow(unused_variables)] window: tauri::Window, #[allow(unused_variables)] count: u32, ) { #[cfg(target_os = "macos")] @@ -246,7 +248,15 @@ pub fn run() { let app = builder .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_opener::init()) + // The plugin's link script would run in built-in browser pages too, + // swallowing plain clicks on target=_blank links: it cancels them and + // calls an opener command remote pages may not use. The app's own + // links open explicitly, so the script is not needed. + .plugin( + tauri_plugin_opener::Builder::new() + .open_js_links_on_click(false) + .build(), + ) .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_window_state::Builder::default() @@ -274,13 +284,13 @@ pub fn run() { quick_composer::init(app.handle())?; mono_chat::init(app.handle())?; macos::install_dock_menu(app.handle()); - if let Some(window) = app.get_webview_window("main") { + if let Some(window) = app.get_window("main") { macos::install(&window); } } #[cfg(not(target_os = "macos"))] { - if let Some(window) = app.get_webview_window("main") { + if let Some(window) = app.get_window("main") { let _ = window.set_decorations(false); let _ = window.set_shadow(true); } @@ -291,6 +301,17 @@ pub fn run() { menu::dispatch(app, event.id().as_ref()); }) .invoke_handler(tauri::generate_handler![ + browser::browser_open, + browser::browser_url, + browser::browser_set_bounds, + browser::browser_set_visible, + browser::browser_focus, + browser::browser_navigate, + browser::browser_history, + browser::browser_close, + browser::browser_retain, + browser::browser_eval, + browser::browser_screenshot, remote::remote_machines, remote::remote_connect, remote::remote_disconnect, @@ -301,6 +322,7 @@ pub fn run() { remote::remote_ssh_answer, remote::remote_ssh_cancel, control::control_enable, + control::control_browser_mcp, control::control_disable, control::control_reply, control::control_save, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 0ede0f82d3..8d7ff281ca 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -44,7 +44,7 @@ use objc2_app_kit::{ }; use objc2_foundation::{NSDictionary, NSEdgeInsets, NSNumber, NSSize, NSString, NSUserDefaults}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -use tauri::{AppHandle, Manager, WebviewWindow, WindowEvent}; +use tauri::{AppHandle, Manager, Window, WindowEvent}; /// Must match the HTML title bar (`h-10` = 40px). const TAB_BAR_HEIGHT: f64 = 40.0; @@ -91,7 +91,7 @@ pub fn register_spellcheck_default() { unsafe { NSUserDefaults::standardUserDefaults().registerDefaults(&defaults) }; } -pub fn install(window: &WebviewWindow) { +pub fn install(window: &Window) { // Opaque for the dock bounce so the first frames are a solid field, // not a frosted desktop. Glass turns on after the first UI paint. prepare_launch(window); @@ -115,7 +115,7 @@ pub fn install(window: &WebviewWindow) { /// Slack-style red count on the Dock icon. `count` is this window's pending /// approvals; the tile shows the sum across windows. -pub fn set_window_badge(window: &WebviewWindow, count: u32) { +pub fn set_window_badge(window: &Window, count: u32) { let label = window.label().to_string(); let apply = move || paint_window_badge(&label, count); if MainThreadMarker::new().is_some() { @@ -165,7 +165,7 @@ fn paint_window_badge(label: &str, count: u32) { } } -pub fn set_visible(window: &WebviewWindow, visible: bool) { +pub fn set_visible(window: &Window, visible: bool) { let Some(ns_window) = ns_window(window) else { return; }; @@ -176,7 +176,7 @@ pub fn set_visible(window: &WebviewWindow, visible: bool) { } } -pub fn set_background_blur_radius(window: &WebviewWindow, radius: u8) { +pub fn set_background_blur_radius(window: &Window, radius: u8) { let radius = radius.clamp(BLUR_MIN, BLUR_MAX); BLUR_RADIUS.store(radius, Ordering::Relaxed); if glass_enabled(window) { @@ -188,14 +188,14 @@ fn glass_windows() -> &'static Mutex> { GLASS_WINDOWS.get_or_init(|| Mutex::new(HashSet::new())) } -fn glass_enabled(window: &WebviewWindow) -> bool { +fn glass_enabled(window: &Window) -> bool { glass_windows() .lock() .unwrap_or_else(|err| err.into_inner()) .contains(window.label()) } -fn set_glass_enabled(window: &WebviewWindow, enabled: bool) { +fn set_glass_enabled(window: &Window, enabled: bool) { let mut windows = glass_windows() .lock() .unwrap_or_else(|err| err.into_inner()); @@ -207,7 +207,7 @@ fn set_glass_enabled(window: &WebviewWindow, enabled: bool) { } /// Solid field behind the dock bounce. Same colour as the HTML sheet. -fn prepare_launch(window: &WebviewWindow) { +fn prepare_launch(window: &Window) { set_launch_background(window, 23, 23, 23); let Some(ns_window) = ns_window(window) else { return; @@ -217,7 +217,7 @@ fn prepare_launch(window: &WebviewWindow) { ns_window.setTitlebarSeparatorStyle(NSTitlebarSeparatorStyle::None); } -fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { +fn set_launch_background(window: &Window, r: u8, g: u8, b: u8) { let Some(ns_window) = ns_window(window) else { return; }; @@ -237,7 +237,7 @@ fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { /// Borderless chats need their rounded shape and fill in AppKit, so newly /// exposed resize edges never wait for a WebKit frame or CSS backdrop filter. -pub fn prepare_floating_window(window: &WebviewWindow) { +pub fn prepare_floating_window(window: &Window) { let Some(native) = ns_window(window) else { return; }; @@ -313,7 +313,7 @@ fn floating_corner_mask() -> Retained { mask } -fn set_native_background(window: &WebviewWindow, native: &NSWindow, color: &NSColor) { +fn set_native_background(window: &Window, native: &NSWindow, color: &NSColor) { if window.label().starts_with(crate::window::MONO_CHAT_PREFIX) { if let Some(layer) = native.contentView().and_then(|view| { view.subviews() @@ -336,11 +336,7 @@ fn set_native_background(window: &WebviewWindow, native: &NSWindow, color: &NSCo /// Turn on desktop blur after the first UI paint. Report whether AppKit paints /// the tint so the page can stop painting the same translucent colour twice. -pub fn enable_glass( - window: &WebviewWindow, - background: crate::window::Rgb, - opacity: Option, -) -> bool { +pub fn enable_glass(window: &Window, background: crate::window::Rgb, opacity: Option) -> bool { let tinted = prepare_glass(window, background, opacity); if !glass_enabled(window) { apply_blur(window, BLUR_RADIUS.load(Ordering::Relaxed)); @@ -350,17 +346,13 @@ pub fn enable_glass( } /// Turn off the blur and fall back to an opaque window in the caller's colour. -pub fn disable_glass(window: &WebviewWindow, r: u8, g: u8, b: u8) { +pub fn disable_glass(window: &Window, r: u8, g: u8, b: u8) { set_glass_enabled(window, false); apply_blur(window, 0); set_launch_background(window, r, g, b); } -fn prepare_glass( - window: &WebviewWindow, - background: crate::window::Rgb, - opacity: Option, -) -> bool { +fn prepare_glass(window: &Window, background: crate::window::Rgb, opacity: Option) -> bool { let Some(ns_window) = ns_window(window) else { return false; }; @@ -438,7 +430,7 @@ fn set_glass_backing(window: &NSWindow, enabled: bool) { content.addSubview_positioned_relativeTo(&backing, NSWindowOrderingMode::Below, None); } -fn apply_blur(window: &WebviewWindow, radius: u8) { +fn apply_blur(window: &Window, radius: u8) { // This API blurs the window rectangle independently of CALayer clipping. // The floating panel's masked AppKit material owns its backdrop instead. let radius = if window.label().starts_with(crate::window::MONO_CHAT_PREFIX) { @@ -464,7 +456,7 @@ fn apply_blur(window: &WebviewWindow, radius: u8) { } } -fn pin(window: &WebviewWindow) -> bool { +fn pin(window: &Window) -> bool { let Some(ns_window) = ns_window(window) else { return PINNED.load(Ordering::Relaxed); }; @@ -477,14 +469,14 @@ fn pin(window: &WebviewWindow) -> bool { true } -fn stretch_titlebar(window: &WebviewWindow) { +fn stretch_titlebar(window: &Window) { let Some(ns_window) = ns_window(window) else { return; }; unsafe { stretch_ns_window(&ns_window) } } -pub(crate) fn ns_window(window: &WebviewWindow) -> Option> { +pub(crate) fn ns_window(window: &impl HasWindowHandle) -> Option> { let Ok(handle) = window.window_handle() else { return None; }; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8a8547574a..595199c8e0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,6 +9,9 @@ fn main() { std::env::args().skip(2).collect(), )); } + if std::env::args().nth(1).as_deref() == Some("browser-mcp") { + std::process::exit(monocode_lib::browser_mcp::run()); + } if std::env::args().nth(1).as_deref() == Some("app") { std::process::exit(monocode_lib::control_cli::run_app( std::env::args().skip(2).collect(), diff --git a/src-tauri/src/mono_chat.rs b/src-tauri/src/mono_chat.rs index 364aae3f92..9d47ea3c74 100644 --- a/src-tauri/src/mono_chat.rs +++ b/src-tauri/src/mono_chat.rs @@ -13,7 +13,7 @@ use tauri::menu::{IconMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder}; use tauri::tray::TrayIconBuilder; use tauri::{ AppHandle, Emitter, EventTarget, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, - WebviewWindowBuilder, + WebviewWindowBuilder, Window, }; use crate::window::{is_workspace_window, workspace_windows, MONO_CHAT_PREFIX}; @@ -343,7 +343,7 @@ fn changed(app: &AppHandle, mono_id: &str) { } } -fn workspace(window: &WebviewWindow) -> Result<(), String> { +fn workspace(window: &Window) -> Result<(), String> { if is_workspace_window(window.label()) { Ok(()) } else { @@ -351,12 +351,12 @@ fn workspace(window: &WebviewWindow) -> Result<(), String> { } } -fn panel(app: &AppHandle, window: &WebviewWindow) -> Result { +fn panel(app: &AppHandle, label: &str) -> Result { app.state::() .0 .lock() .unwrap() - .shown(window.label()) + .shown(label) .map(str::to_owned) .ok_or_else(|| "This action belongs to the floating chat.".into()) } @@ -364,7 +364,7 @@ fn panel(app: &AppHandle, window: &WebviewWindow) -> Result { #[tauri::command] pub fn mono_chat_sync( app: AppHandle, - window: WebviewWindow, + window: Window, monos: Vec, hosted: Vec, mascots: Vec, @@ -440,7 +440,9 @@ fn owner(app: &AppHandle, mono_id: &str) -> Result { if let Some(label) = inner .owners .get(mono_id) - .filter(|label| app.get_webview_window(label).is_some()) + // A workspace window that hosts browser tabs is no longer a + // WebviewWindow, so look it up as a plain window. + .filter(|label| app.get_window(label).is_some()) { return Ok(label.clone()); } @@ -461,7 +463,9 @@ fn owner(app: &AppHandle, mono_id: &str) -> Result { drop(inner); let window = match windows.first() { Some(window) => window.clone(), - None => crate::window::open_session_window(app, false)?, + None => crate::window::open_session_window(app, false)? + .as_ref() + .window(), }; let label = window.label().to_owned(); state @@ -530,7 +534,7 @@ fn select(app: &AppHandle, mono_id: &str) -> Result<(), String> { /// Point a floating window at another Mono. The window stays put; only its /// conversation changes, and a Mono opened before shows its last snapshot. -fn switch(app: &AppHandle, window: &WebviewWindow, to: &str) -> Result<(), String> { +fn switch(app: &AppHandle, window: &Window, to: &str) -> Result<(), String> { let owner = owner(app, to)?; { let state = app.state::(); @@ -547,7 +551,7 @@ fn switch(app: &AppHandle, window: &WebviewWindow, to: &str) -> Result<(), Strin #[tauri::command] pub fn mono_chat_switch( app: AppHandle, - window: WebviewWindow, + window: Window, from: String, to: String, ) -> Result<(), String> { @@ -555,8 +559,8 @@ pub fn mono_chat_switch( // A Mono added from a floating chat: switch the window that asked. let mut showing = panels(&app, &from); showing.sort_by_key(|w| !w.is_visible().unwrap_or(false)); - showing.into_iter().next() - } else if panel(&app, &window)? == from { + showing.into_iter().next().map(|w| w.as_ref().window()) + } else if panel(&app, window.label())? == from { Some(window) } else { return Err("This action belongs to a different Mono.".into()); @@ -629,7 +633,7 @@ fn build(app: &AppHandle, mono_id: &str) -> tauri::Result { // discarded on failure, so catching this unwind cannot expose it later. if let Err(exception) = objc2::exception::catch(AssertUnwindSafe(|| { crate::quick_composer::make_panel(&window); - crate::macos::prepare_floating_window(&window); + crate::macos::prepare_floating_window(&window.as_ref().window()); })) { let _ = window.destroy(); return Err(std::io::Error::other(format!( @@ -677,7 +681,7 @@ fn build(app: &AppHandle, mono_id: &str) -> tauri::Result { #[tauri::command] pub async fn mono_chat_ready(app: AppHandle, window: WebviewWindow) -> Result<(), String> { - panel(&app, &window)?; + panel(&app, window.label())?; let (tx, mut rx) = tauri::async_runtime::channel(1); let handle = app.clone(); app.run_on_main_thread(move || { @@ -714,7 +718,7 @@ pub async fn mono_chat_ready(app: AppHandle, window: WebviewWindow) -> Result<() #[tauri::command] pub fn mono_chat_state(app: AppHandle, window: WebviewWindow) -> Result { - let id = panel(&app, &window)?; + let id = panel(&app, window.label())?; Ok(app .state::() .0 @@ -727,7 +731,7 @@ pub fn mono_chat_state(app: AppHandle, window: WebviewWindow) -> Result Result<(), String> { @@ -744,7 +748,7 @@ pub fn mono_chat_publish( } #[tauri::command] -pub fn mono_chat_take(app: AppHandle, window: WebviewWindow) -> Result, String> { +pub fn mono_chat_take(app: AppHandle, window: Window) -> Result, String> { workspace(&window)?; Ok(app .state::() @@ -759,7 +763,7 @@ pub fn mono_chat_take(app: AppHandle, window: WebviewWindow) -> Result Result { +pub fn mono_chat_accept(app: AppHandle, window: Window, id: u32) -> Result { workspace(&window)?; Ok(app .state::() @@ -772,7 +776,7 @@ pub fn mono_chat_accept(app: AppHandle, window: WebviewWindow, id: u32) -> Resul #[tauri::command] pub fn mono_chat_reply( app: AppHandle, - window: WebviewWindow, + window: Window, id: u32, error: Option, ) -> Result<(), String> { @@ -819,7 +823,7 @@ pub async fn mono_chat_action( mono_id: String, action: Value, ) -> Result<(), String> { - if panel(&app, &window)? != mono_id { + if panel(&app, window.label())? != mono_id { return Err("This action belongs to a different Mono.".into()); } let kind = action @@ -884,7 +888,7 @@ pub async fn mono_chat_action( } #[tauri::command] -pub fn mono_chat_keep_alive(app: AppHandle, window: WebviewWindow) -> bool { +pub fn mono_chat_keep_alive(app: AppHandle, window: Window) -> bool { let state = app.state::(); let inner = state.0.lock().unwrap(); inner.views.keys().any(|id| { diff --git a/src-tauri/src/quick_composer.rs b/src-tauri/src/quick_composer.rs index 784fb4cb30..83ea4e8153 100644 --- a/src-tauri/src/quick_composer.rs +++ b/src-tauri/src/quick_composer.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; use tauri::window::{Effect, EffectState, EffectsBuilder}; use tauri::{ AppHandle, Emitter, Manager, PhysicalPosition, State, WebviewUrl, WebviewWindow, - WebviewWindowBuilder, + WebviewWindowBuilder, Window, }; use tauri_plugin_global_shortcut::{ GlobalShortcutExt, Modifiers, Shortcut, ShortcutEvent, ShortcutState, @@ -141,7 +141,7 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { /// the application on webview creation, so recheck native focus at execution /// time: the user may have switched apps since the frontend scheduled it. #[tauri::command] -pub async fn quick_composer_prepare(app: AppHandle, window: WebviewWindow) -> Result { +pub async fn quick_composer_prepare(app: AppHandle, window: Window) -> Result { if !crate::window::is_workspace_window(window.label()) { return Err("Only a workspace can prepare the composer.".into()); } @@ -294,6 +294,7 @@ pub async fn quick_composer_submit( // a second submission sees the first submission's mounting window. let target = target_or_create(launch_target(&handle), request.reveal, |reveal| { crate::window::open_session_window(&handle, reveal) + .map(|window| window.as_ref().window()) })?; screenshots::persist(&handle, &mut request.attachments)?; let reveal = request.reveal; @@ -424,7 +425,7 @@ fn capture_screenshot() -> Result, String> { #[tauri::command] pub fn quick_composer_take( app: AppHandle, - window: WebviewWindow, + window: Window, state: State<'_, QuickComposerState>, ) -> Result, String> { if !crate::window::is_workspace_window(window.label()) { @@ -434,14 +435,12 @@ pub fn quick_composer_take( .pending .lock() .map_err(|err| err.to_string())? - .claim(window.label(), |label| { - app.get_webview_window(label).is_some() - })) + .claim(window.label(), |label| app.get_window(label).is_some())) } #[tauri::command] pub fn quick_composer_ack( - window: WebviewWindow, + window: Window, state: State<'_, QuickComposerState>, id: String, ) -> Result<(), String> { @@ -469,7 +468,7 @@ fn target_or_create( /// The window the user last looked at, else the first one. Hidden windows /// count: close-to-dock keeps them running. -fn launch_target(app: &AppHandle) -> Option { +fn launch_target(app: &AppHandle) -> Option { let windows = workspace_windows(app); windows .iter() diff --git a/src-tauri/src/reminders.rs b/src-tauri/src/reminders.rs index 9201173e21..8e273a6c23 100644 --- a/src-tauri/src/reminders.rs +++ b/src-tauri/src/reminders.rs @@ -4,7 +4,7 @@ use std::time::Duration; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; +use tauri::{AppHandle, Emitter, Manager, State, Window}; use crate::session_store::{now_millis, validate_id, SessionStore}; @@ -214,7 +214,7 @@ pub fn reminder_configure( #[tauri::command] pub fn reminder_register_window( - window: WebviewWindow, + window: Window, service: State<'_, ReminderService>, session_ids: Vec, ) -> Result<(), String> { @@ -235,7 +235,7 @@ pub fn reminder_open(app: AppHandle, session_id: String, due_at: i64) -> Result< #[tauri::command] pub fn reminder_take_open( app: AppHandle, - window: WebviewWindow, + window: Window, service: State<'_, ReminderService>, ) -> Result, String> { let mut pending = service @@ -246,7 +246,7 @@ pub fn reminder_take_open( request .window_label .as_deref() - .is_none_or(|label| label == window.label() || app.get_webview_window(label).is_none()) + .is_none_or(|label| label == window.label() || app.get_window(label).is_none()) }) { return Ok(pending.take()); } @@ -272,11 +272,11 @@ pub(crate) fn open_from_notification(app: &AppHandle, identifier: &str) { fn queue_open(app: &AppHandle, session_id: String, due_at: i64) -> Result<(), String> { let handle = app.clone(); app.run_on_main_thread(move || { - let mut windows = handle.webview_windows(); + let mut windows = handle.windows(); windows.retain(|label, _| crate::window::is_workspace_window(label)); let service = handle.state::(); let owners = service.window_sessions.lock().ok(); - let owns_session = |window: &&WebviewWindow| { + let owns_session = |window: &&Window| { owners.as_ref().is_some_and(|owners| { owners .get(window.label()) diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index de1d4e773f..c1ce191c45 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -9,7 +9,9 @@ use serde::{Deserialize, Serialize}; use tauri::window::Color; #[cfg(target_os = "windows")] use tauri::window::{Effect, EffectsBuilder}; -use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow, WebviewWindowBuilder}; +use tauri::{ + AppHandle, Emitter, EventTarget, Manager, Webview, WebviewWindow, WebviewWindowBuilder, Window, +}; static WINDOW_COUNTER: AtomicU32 = AtomicU32::new(1); @@ -111,7 +113,7 @@ pub fn open_session_window(app: &AppHandle, reveal: bool) -> Result, ) -> bool { + // Browser panels make this a multi-webview window, so paint the window + // and its app webview separately instead of through a WebviewWindow. + let window = webview.window(); + let set_background_color = |color: Option| { + let _ = window.set_background_color(color); + let _ = webview.set_background_color(color); + }; #[cfg(target_os = "macos")] { if enabled { @@ -160,7 +169,7 @@ pub fn set_window_glass_enabled( } else { 3 }; - let _ = window.set_background_color(Some(Color(0, 0, 0, alpha))); + set_background_color(Some(Color(0, 0, 0, alpha))); return crate::macos::enable_glass(&window, background, opacity); } else { crate::macos::disable_glass(&window, background.r, background.g, background.b); @@ -169,18 +178,18 @@ pub fn set_window_glass_enabled( #[cfg(target_os = "windows")] { if enabled { - let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); + set_background_color(Some(Color(0, 0, 0, 0))); let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); } else { let _ = window.set_effects(None); - let _ = window.set_background_color(Some(background.fill())); + set_background_color(Some(background.fill())); } } #[cfg(target_os = "linux")] { // No system blur API on Linux: transparency only. The compositor // (e.g. Mutter) blends the translucent CSS glass over the desktop. - let _ = window.set_background_color(Some(if enabled { + set_background_color(Some(if enabled { Color(0, 0, 0, 0) } else { background.fill() @@ -188,7 +197,7 @@ pub fn set_window_glass_enabled( } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { - let _ = (window, enabled, background); + let _ = (set_background_color, enabled, background); } #[cfg(not(target_os = "macos"))] let _ = opacity; @@ -199,14 +208,14 @@ pub fn set_window_glass_enabled( /// Close with a running chat hides the webview so the harness child keeps going. #[tauri::command] -pub fn hide_window(window: WebviewWindow) -> Result<(), String> { +pub fn hide_window(window: Window) -> Result<(), String> { window.hide().map_err(|err| err.to_string()) } /// Finish an idle close. `destroy` skips CloseRequested so the JS handler /// does not loop; `close` would fire it again. #[tauri::command] -pub fn destroy_window(window: WebviewWindow) -> Result<(), String> { +pub fn destroy_window(window: Window) -> Result<(), String> { window.destroy().map_err(|err| err.to_string()) } @@ -218,9 +227,9 @@ pub fn is_workspace_window(label: &str) -> bool { } /// Every workspace window, sorted by label. -pub fn workspace_windows(app: &AppHandle) -> Vec { - let mut windows: Vec = app - .webview_windows() +pub fn workspace_windows(app: &AppHandle) -> Vec { + let mut windows: Vec = app + .windows() .into_values() .filter(|window| is_workspace_window(window.label())) .collect(); @@ -396,7 +405,7 @@ pub fn request_quit(app: &AppHandle) { /// One window's live turn count, counted before anything is killed. #[tauri::command] -pub fn quit_poll_reply(app: AppHandle, window: WebviewWindow, id: u32, in_flight: u32) { +pub fn quit_poll_reply(app: AppHandle, window: Window, id: u32, in_flight: u32) { let next = record_reply(&mut QUIT_RUN.lock().unwrap(), id, window.label(), in_flight); if next == Next::Confirm { start_confirm(&app, id); @@ -414,7 +423,7 @@ fn resurface_prompt(app: &AppHandle) { } run.prompt.clone() }; - let Some(window) = label.and_then(|label| app.get_webview_window(&label)) else { + let Some(window) = label.and_then(|label| app.get_window(&label)) else { return; }; let _ = window.unminimize(); @@ -424,7 +433,7 @@ fn resurface_prompt(app: &AppHandle) { /// The answer to the one dialog the whole app gets to show. #[tauri::command] -pub fn quit_decision(app: AppHandle, window: WebviewWindow, id: u32, confirmed: bool) { +pub fn quit_decision(app: AppHandle, window: Window, id: u32, confirmed: bool) { { let guard = QUIT_RUN.lock().unwrap(); let Some(run) = guard.as_ref() else { return }; @@ -446,7 +455,7 @@ pub fn quit_decision(app: AppHandle, window: WebviewWindow, id: u32, confirmed: /// One window has persisted. The last one out turns the lights off, so a slow /// window cannot lose its workspace to a faster window's exit. #[tauri::command] -pub fn quit_ready(app: AppHandle, window: WebviewWindow, id: u32, persisted: bool) { +pub fn quit_ready(app: AppHandle, window: Window, id: u32, persisted: bool) { // A window that could not save its workspace keeps the app open, the way a // failed persist did before the handshake existed. The windows that did // save are staying too, so take them back out of quitting. @@ -545,7 +554,7 @@ fn start_commit(app: &AppHandle, id: u32) { /// listeners are live. A window still booting would swallow the dialog, and /// confirming has nothing to time out on. fn prompt_window(app: &AppHandle, replied: &HashSet) -> Option { - let windows = app.webview_windows(); + let windows = app.windows(); let labels = workspace_labels(app); let answered: Vec = labels .iter() diff --git a/src/app/App.tsx b/src/app/App.tsx index d2d9cdc13e..1e67f96d31 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -659,6 +659,9 @@ import { PaneTree } from "../features/workspace/ui/PaneTree"; import { SessionPane } from "../features/sessions/ui/SessionPane"; import { SessionSurface } from "../features/sessions/ui/SessionSurface"; import { ProjectTerminalDock } from "../features/terminal/ui/ProjectTerminalDock"; +import { BrowserDockLayout } from "../features/browser/ui/SessionBrowserDock"; +import { handleBrowserRequest } from "../features/browser/model/browserAgent"; + import { lazySurface } from "../shared/ui/lazySurface"; import { preloadNavigationWhenIdle } from "./model/preloadNavigation"; import { requestTranscriptJump } from "../features/sessions/model/transcriptJump"; @@ -1831,6 +1834,7 @@ function Workspace({ ? sessions.find((session) => session.id === monoViewId) : undefined; const monoCovers = !!monoViewId; + const activeTabSessionIds = activeTab ? leafIds(activeTab.layout) : []; const activeLinkedWorkItemPanel = activeTab ? (linkedWorkItemPanels.get(activeTab.focusedId) ?? @@ -10666,6 +10670,19 @@ function Workspace({ payload.input, ); } + if (payload.namespace === "browser") { + if ( + !sessionsRef.current.some( + (session) => session.id === payload.sessionId, + ) + ) + throw new Error("This session is not open in MonoCode"); + return handleBrowserRequest( + payload.sessionId, + payload.action, + payload.input, + ); + } if (payload.namespace !== "app") throw new Error("Unknown CLI namespace"); const source = sessionsRef.current.find( @@ -13032,6 +13049,16 @@ function Workspace({ {compactTitleBar ? null : workspaceTitleBar}
+ {[...linkedWorkItemPanels.values()].map((panel) => ( 0 } - projectTerminalOpen={ - !monoCovers && !!currentProjectDock?.open - } + projectTerminalOpen={!monoCovers && dockVisible} + showBrowser /> )} diff --git a/src/app/shell/UsageFooter.test.ts b/src/app/shell/UsageFooter.test.ts index 2ba3a0fb84..f78b9d5945 100644 --- a/src/app/shell/UsageFooter.test.ts +++ b/src/app/shell/UsageFooter.test.ts @@ -53,7 +53,7 @@ describe("UsageFooter terminal control", () => { const markup = renderToStaticMarkup( createElement(UsageFooter, { providers: [], - onNewTerminal: vi.fn(), + onToggleProjectTerminal: vi.fn(), }), ); @@ -155,3 +155,21 @@ describe("UsageFooter terminal control", () => { expect(onToggleProjectTerminal).not.toHaveBeenCalled(); }); }); + +describe("toggle-only terminal control", () => { + it("shows the terminal button pressed only while the dock is open", () => { + const render = (projectTerminalOpen: boolean) => + renderToStaticMarkup( + createElement(UsageFooter, { + providers: [], + onToggleProjectTerminal: vi.fn(), + projectTerminalOpen, + }), + ); + + expect(render(false)).toContain('aria-pressed="false"'); + expect(render(false)).toContain("Show Terminal"); + expect(render(true)).toContain('aria-pressed="true"'); + expect(render(true)).toContain("Hide Terminal"); + }); +}); diff --git a/src/app/shell/UsageFooter.tsx b/src/app/shell/UsageFooter.tsx index 3489f56c90..6f3730bdd3 100644 --- a/src/app/shell/UsageFooter.tsx +++ b/src/app/shell/UsageFooter.tsx @@ -1,3 +1,4 @@ +import { BrowserStatusButton } from "../../features/browser/ui/BrowserStatusButton"; import { RefreshCw, Terminal } from "../../shared/ui/icons"; import { useCallback, @@ -72,6 +73,7 @@ export function UsageFooter({ terminals = [], terminalOpen = false, onToggleTerminal, + showBrowser = false, onNewTerminal, onToggleProjectTerminal, projectTerminalActive = false, @@ -85,6 +87,7 @@ export function UsageFooter({ terminals?: RunningTerminal[]; terminalOpen?: boolean; onToggleTerminal?: (fileId: string) => void; + showBrowser?: boolean; onNewTerminal?: () => void; onToggleProjectTerminal?: () => void; projectTerminalActive?: boolean; @@ -103,9 +106,11 @@ export function UsageFooter({ const showTerminalButton = Boolean( onNewTerminal || onToggleProjectTerminal, ); - const terminalLabel = projectTerminalActive - ? "Terminal" - : `New Terminal (${MOD}\`)`; + const terminalLabel = !onNewTerminal + ? `${projectTerminalOpen ? "Hide" : "Show"} Terminal (${MOD}J)` + : projectTerminalActive + ? "Terminal" + : `New Terminal (${MOD}\`)`; const onTerminalClick = projectTerminalActive ? (onToggleProjectTerminal ?? onNewTerminal) : (onNewTerminal ?? onToggleProjectTerminal); @@ -137,8 +142,9 @@ export function UsageFooter({ ) : null } /> - {showTerminals || showTerminalButton ? ( + {showTerminals || showTerminalButton || showBrowser ? (
+ {showBrowser ? : null} {showTerminals ? ( (OVERLAY_SELECTOR)) { + if (host.contains(el)) continue; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + if (boxesOverlap(box, rect)) return true; + } + return false; +} + +/** + * Native webviews sit above every DOM layer, so menus, popovers and dialogs + * that overlap the host would be drawn underneath the page. Report when that + * happens so the caller can hide the native view until the overlay closes. + */ +export function useOverlayOcclusion( + host: RefObject, + enabled: boolean, +): boolean { + const [covered, setCovered] = useState(false); + + useEffect(() => { + if (!enabled) { + setCovered(false); + return; + } + let frame: number | null = null; + const check = () => { + frame = null; + const el = host.current; + setCovered(el ? overlayCovers(el) : false); + }; + const schedule = () => { + if (frame == null) frame = requestAnimationFrame(check); + }; + const observer = new MutationObserver(schedule); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["role", "class", "style", "hidden"], + }); + window.addEventListener("resize", schedule); + schedule(); + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + if (frame != null) cancelAnimationFrame(frame); + }; + }, [enabled, host]); + + return covered; +} diff --git a/src/features/browser/model/browserAgent.test.ts b/src/features/browser/model/browserAgent.test.ts new file mode 100644 index 0000000000..d92f82ef31 --- /dev/null +++ b/src/features/browser/model/browserAgent.test.ts @@ -0,0 +1,698 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const evals: string[] = []; +vi.mock("../../../platform/tauri/browser", () => ({ + browserHistory: vi.fn(), + closeBrowserView: vi.fn(async () => undefined), + evalInBrowser: vi.fn(async (id: string, script: string) => { + // The readyState probe after a load timeout is not a tool call. + if (script === "document.readyState") return "loading"; + evals.push(id); + return "ok"; + }), + navigateBrowser: vi.fn(), + screenshotBrowser: vi.fn(), +})); +vi.mock("../../settings/model/displayPrefs", () => ({ + loadAgentBrowser: () => true, +})); + +import { + closeBrowserTab, + dockOfTab, + noteLoadStarted, + getBrowserState, + openBrowserTab, + patchBrowserTab, +} from "./browserStore"; +import { createBrowserAgent } from "./browserAgent"; +import * as browserStore from "./browserStore"; +import { setNativeTabReady } from "./nativeTabs"; +import { + browserHistory, + evalInBrowser, + navigateBrowser, +} from "../../../platform/tauri/browser"; + +const tab = (id: string) => + dockOfTab(getBrowserState(), id)?.pane.files.find((f) => f.id === id) + ?.browser; + +const opened: string[] = []; +const openTab = () => { + const id = openBrowserTab("https://example.com", { + sessionId: "s1", + background: true, + })!; + opened.push(id); + return id; +}; + +/** Settle a call's outcome without leaving a rejection unhandled. */ +function track(promise: Promise) { + const state = { + done: false, + value: undefined as unknown, + error: undefined as unknown, + }; + void promise.then( + (value) => { + state.done = true; + state.value = value; + }, + (error: unknown) => { + state.done = true; + state.error = error; + }, + ); + return state; +} + +describe("browser tools waiting on a tab", () => { + beforeEach(() => { + vi.clearAllMocks(); + evals.length = 0; + vi.useFakeTimers(); + }); + + afterEach(() => { + for (const id of opened.splice(0)) { + setNativeTabReady(id, false); + if (tab(id)) closeBrowserTab(id); + } + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + const evalCall = (id: string) => + createBrowserAgent("s1")("browser_eval", { tabId: id, script: "1" }); + + it("waits for the page to finish loading after resuming", async () => { + const id = openTab(); + const call = track(evalCall(id)); + + await vi.advanceTimersByTimeAsync(100); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(2_000); + expect(evals).toHaveLength(0); + expect(call.done).toBe(false); + + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(evals).toEqual([id]); + }); + + it("holds every concurrent call until a ready but loading page finishes", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + const calls = [ + track(evalCall(id)), + track(evalCall(id)), + track(evalCall(id)), + ]; + + await vi.advanceTimersByTimeAsync(2_000); + expect(evals).toHaveLength(0); + expect(calls.some((c) => c.done)).toBe(false); + + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(10); + expect(calls.every((c) => c.done && !c.error)).toBe(true); + expect(evals).toEqual([id, id, id]); + }); + + it("holds concurrent calls on a suspended tab until it has loaded", async () => { + const id = openTab(); + const first = track(evalCall(id)); + const second = track(evalCall(id)); + + await vi.advanceTimersByTimeAsync(100); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(500); + // A third call arrives mid-load, with the view already ready. + const third = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(500); + expect([first, second, third].some((c) => c.done)).toBe(false); + expect(evals).toHaveLength(0); + + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(10); + expect([first, second, third].every((c) => c.done && !c.error)).toBe(true); + expect(evals).toHaveLength(3); + }); + + it("fails clearly when the page never finishes loading", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + const call = track(evalCall(id)); + + await vi.advanceTimersByTimeAsync(16_000); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/still loading/); + expect(evals).toHaveLength(0); + + // A later call is not stuck behind the failed wait. + patchBrowserTab(id, { loading: false }); + const retry = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(10); + expect(retry.done && !retry.error).toBe(true); + expect(evals).toEqual([id]); + }); + + it.each(["complete", "interactive"])( + "recovers a tab stuck loading when the page reports %s", + async (readyState) => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + vi.mocked(evalInBrowser).mockImplementation(async (_id, script) => { + if (script === "document.readyState") return readyState; + evals.push(id); + return "ok"; + }); + try { + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(16_000); + expect(call.done && !call.error).toBe(true); + expect(tab(id)?.loading).toBeFalsy(); + + // The next call is not held up by the stale flag. + const next = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(10); + expect(next.done && !next.error).toBe(true); + } finally { + vi.mocked(evalInBrowser).mockImplementation( + async (id: string, script: string) => { + if (script === "document.readyState") return "loading"; + evals.push(id); + return "ok"; + }, + ); + } + }, + ); + + it("keeps reporting a load that the page says is still loading", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(16_000); + expect(String(call.error)).toMatch(/still loading/); + expect(tab(id)?.loading).toBe(true); + }); + + /** Time out a load, leaving the readyState probe pending. */ + async function pendingProbe(reply: () => Promise) { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + vi.mocked(evalInBrowser).mockImplementation(async (_id, script) => { + if (script === "document.readyState") return reply(); + evals.push(id); + return "ok"; + }); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(16_000); + return { id, call }; + } + + const restoreEval = () => + vi + .mocked(evalInBrowser) + .mockImplementation(async (id: string, script: string) => { + if (script === "document.readyState") return "loading"; + evals.push(id); + return "ok"; + }); + + it("keeps a newer load that starts while the probe is in flight", async () => { + let answer!: (value: unknown) => void; + const { id, call } = await pendingProbe( + () => new Promise((resolve) => (answer = resolve)), + ); + try { + expect(call.done).toBe(false); + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + answer("complete"); + await vi.advanceTimersByTimeAsync(10); + expect(String(call.error)).toMatch(/still loading/); + expect(tab(id)?.loading).toBe(true); + } finally { + restoreEval(); + } + }); + + it("reports a closed tab when it closes during a successful probe", async () => { + let answer!: (value: unknown) => void; + const { id, call } = await pendingProbe( + () => new Promise((resolve) => (answer = resolve)), + ); + try { + closeBrowserTab(id); + answer("complete"); + await vi.advanceTimersByTimeAsync(10); + expect(String(call.error)).toMatch(/closed/); + } finally { + restoreEval(); + } + }); + + it("reports a closed tab when it closes during a failing probe", async () => { + let fail!: (reason: unknown) => void; + const { id, call } = await pendingProbe( + () => new Promise((_resolve, reject) => (fail = reject)), + ); + try { + closeBrowserTab(id); + fail(new Error("gone")); + await vi.advanceTimersByTimeAsync(10); + expect(String(call.error)).toMatch(/closed/); + } finally { + restoreEval(); + } + }); + + it("still reports loading when the probe rejects on a live tab", async () => { + const { id, call } = await pendingProbe(() => + Promise.reject(new Error("unresponsive")), + ); + try { + expect(String(call.error)).toMatch(/still loading/); + expect(tab(id)?.loading).toBe(true); + } finally { + restoreEval(); + } + }); + + it("fails when the tab is closed mid-wait", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(100); + + closeBrowserTab(id); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/closed/); + expect(evals).toHaveLength(0); + }); + + it("fails when the tab is closed before its view appears", async () => { + const id = openTab(); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(100); + + closeBrowserTab(id); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/closed/); + expect(evals).toHaveLength(0); + }); + + it("fails fast when the view cannot be created", async () => { + const id = openTab(); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(100); + + patchBrowserTab(id, { error: "webview failed" }); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/webview failed/); + expect(evals).toHaveLength(0); + }); + + it("does not leave loading stuck when the view is reused without a load", async () => { + const id = openTab(); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(100); + + // A reused native view: ready again, but no load event ever follows. + setNativeTabReady(id, true); + await vi.advanceTimersByTimeAsync(3_100); + expect(call.done).toBe(true); + expect(call.error).toBeUndefined(); + expect(evals).toEqual([id]); + expect(tab(id)?.loading).toBeFalsy(); + }); + + it("runs straight away on a ready page that already finished loading", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track(evalCall(id)); + await vi.advanceTimersByTimeAsync(0); + expect(call.done && !call.error).toBe(true); + expect(evals).toEqual([id]); + }); + + it("browser_open fails instead of timing out when creation fails", async () => { + const agent = createBrowserAgent("s1"); + const call = track(agent("browser_open", { url: "https://example.com" })); + await vi.advanceTimersByTimeAsync(10); + const id = getBrowserState().docks.flatMap((d) => + d.pane.files.map((f) => f.id), + )[0]; + opened.push(id); + + patchBrowserTab(id, { error: "webview failed" }); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/webview failed/); + }); + + it("shares one load wait between concurrent callers", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: true }); + const subscribe = vi.spyOn(browserStore, "subscribeBrowser"); + const first = track(evalCall(id)); + const second = track(evalCall(id)); + expect(subscribe).toHaveBeenCalledTimes(1); + subscribe.mockRestore(); + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(0); + expect(first.error).toBeUndefined(); + expect(second.error).toBeUndefined(); + expect(evals).toEqual([id, id]); + }); + + it("fails fast when resume repeats the same creation error", async () => { + const id = openTab(); + patchBrowserTab(id, { error: "webview failed" }); + const call = track(evalCall(id)); + expect(tab(id)?.error).toBeUndefined(); + patchBrowserTab(id, { error: "webview failed" }); + await vi.advanceTimersByTimeAsync(10); + expect(call.done).toBe(true); + expect(String(call.error)).toMatch(/webview failed/); + }); + + it("does not wait out the full timeout when an unknown outcome never loads", async () => { + const id = openTab(); + setNativeTabReady(id, true); + vi.mocked(navigateBrowser).mockResolvedValueOnce({ outcome: "unknown" }); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/#hash", + }), + ); + await vi.advanceTimersByTimeAsync(1_600); + expect(call.done).toBe(true); + expect(call.value).not.toHaveProperty("timedOut"); + }); + + it("does not count a load that began before the navigation was dispatched", async () => { + const id = openTab(); + setNativeTabReady(id, true); + vi.mocked(navigateBrowser).mockImplementationOnce( + async (_id, _url, onDispatch) => { + // An earlier navigation's load started while this one was queued. + noteLoadStarted(id); + onDispatch?.(); + return { outcome: "document" }; + }, + ); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/next", + }), + ); + await vi.advanceTimersByTimeAsync(5_000); + // The earlier load does not complete this navigation's wait. + expect(call.done).toBe(false); + }); + + it("navigates without load events and leaves the tab usable", async () => { + const id = openTab(); + setNativeTabReady(id, true); + // The page applied the fragment itself and said so. + vi.mocked(navigateBrowser).mockResolvedValueOnce({ outcome: "applied" }); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/#hash", + }), + ); + await vi.advanceTimersByTimeAsync(1_600); + expect(call.done).toBe(true); + expect(call.error).toBeUndefined(); + expect(tab(id)?.loading).toBeFalsy(); + expect(navigateBrowser).toHaveBeenCalledWith( + id, + "https://example.com/#hash", + expect.any(Function), + ); + await evalCall(id); + expect(evals).toEqual([id]); + }); + + it("waits for a load when the page leaves a fragment target to the webview", async () => { + const id = openTab(); + setNativeTabReady(id, true); + patchBrowserTab(id, { url: "https://example.com/a" }); + // The page was already somewhere else (pushState the store has not seen), + // so the fragment target is a document navigation. + vi.mocked(navigateBrowser).mockResolvedValueOnce({ outcome: "document" }); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/a#section", + }), + ); + await vi.advanceTimersByTimeAsync(1_600); + expect(call.done).toBe(false); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(0); + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(0); + expect(call.done).toBe(true); + }); + + it("waits for normal navigation load events", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/next", + }), + ); + await vi.advanceTimersByTimeAsync(100); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(2_000); + expect(call.done).toBe(false); + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(0); + expect(call.done).toBe(true); + expect(call.value).not.toHaveProperty("timedOut"); + }); + + it("does not report a reload as loaded until the load runs", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")("browser_history", { + tabId: id, + action: "reload", + }), + ); + // No load event within the old start window: not success yet. + await vi.advanceTimersByTimeAsync(5_000); + expect(call.done).toBe(false); + expect(browserHistory).toHaveBeenCalledWith(id, "reload"); + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(2_000); + expect(call.done).toBe(false); + patchBrowserTab(id, { loading: false }); + await vi.advanceTimersByTimeAsync(0); + expect(call.error).toBeUndefined(); + expect(call.value).not.toHaveProperty("timedOut"); + }); + + it("reports timedOut when a reload never starts loading", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")("browser_history", { + tabId: id, + action: "reload", + }), + ); + await vi.advanceTimersByTimeAsync(15_100); + expect(call.error).toBeUndefined(); + expect(call.value).toMatchObject({ tabId: id, timedOut: true }); + expect(tab(id)?.loading).toBeFalsy(); + }); + + it("does not miss a navigation load that finished before the wait began", async () => { + const id = openTab(); + setNativeTabReady(id, true); + vi.mocked(navigateBrowser).mockImplementationOnce(async () => { + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + patchBrowserTab(id, { loading: false }); + }); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/fast", + }), + ); + await vi.advanceTimersByTimeAsync(0); + expect(call.done).toBe(true); + expect(call.value).not.toHaveProperty("timedOut"); + }); + + it("holds a cross-document navigation open when no load starts", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/other", + }), + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(call.done).toBe(false); + await vi.advanceTimersByTimeAsync(10_200); + expect(call.value).toMatchObject({ timedOut: true }); + }); + + it.each(["browser_navigate", "browser_history"])( + "%s summarizes the page after its load finishes", + async (action) => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")(action, { + tabId: id, + url: "https://example.com/final", + action: "reload", + }), + ); + await vi.advanceTimersByTimeAsync(100); + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(100); + patchBrowserTab(id, { + url: "https://example.com/redirected", + title: "Final", + loading: false, + }); + await vi.advanceTimersByTimeAsync(0); + expect(call.value).toMatchObject({ + tabId: id, + url: "https://example.com/redirected", + title: "Final", + }); + }, + ); + + it("summarizes a browser_open after its load finishes", async () => { + const call = track( + createBrowserAgent("s1")("browser_open", { url: "https://example.com" }), + ); + await vi.advanceTimersByTimeAsync(10); + const id = getBrowserState().docks.flatMap((d) => + d.pane.files.map((f) => f.id), + )[0]; + opened.push(id); + setNativeTabReady(id, true); + await vi.advanceTimersByTimeAsync(10); + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + patchBrowserTab(id, { + url: "https://example.com/landed", + title: "Landed", + loading: false, + }); + await vi.advanceTimersByTimeAsync(0); + expect(call.value).toMatchObject({ + url: "https://example.com/landed", + title: "Landed", + }); + }); + + it("clears loading when native navigation rejects", async () => { + const id = openTab(); + setNativeTabReady(id, true); + vi.mocked(navigateBrowser).mockImplementationOnce(async () => { + patchBrowserTab(id, { loading: true }); + throw new Error("navigation failed"); + }); + await expect( + createBrowserAgent("s1")("browser_navigate", { + tabId: id, + url: "https://example.com/next", + }), + ).rejects.toThrow("navigation failed"); + expect(tab(id)?.loading).toBeFalsy(); + expect(tab(id)?.url).toBe("https://example.com"); + await evalCall(id); + expect(evals).toEqual([id]); + }); + + it.each(["browser_navigate", "browser_history"])( + "%s surfaces a load timeout", + async (action) => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")(action, { + tabId: id, + url: "https://example.com/next", + action: "reload", + }), + ); + await vi.advanceTimersByTimeAsync(100); + patchBrowserTab(id, { loading: true }); + await vi.advanceTimersByTimeAsync(15_000); + expect(call.error).toBeUndefined(); + expect(call.value).toMatchObject({ tabId: id, timedOut: true }); + patchBrowserTab(id, { loading: false }); + await evalCall(id); + expect(evals).toEqual([id]); + }, + ); + + it.each([ + "browser_navigate", + "browser_history", + "browser_click", + "browser_open", + ])("%s throws when the tab closes during its load wait", async (action) => { + let id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")(action, { + tabId: id, + url: "https://example.com/next", + action: "reload", + text: "Next", + }), + ); + await vi.advanceTimersByTimeAsync(10); + if (action === "browser_open") { + id = getBrowserState() + .docks.flatMap((d) => d.pane.files.map((f) => f.id)) + .find((candidate) => !opened.includes(candidate))!; + opened.push(id); + setNativeTabReady(id, true); + await vi.advanceTimersByTimeAsync(10); + } + closeBrowserTab(id); + await vi.advanceTimersByTimeAsync(0); + expect(call.done).toBe(true); + expect(String(call.error)).toContain("The browser tab was closed"); + }); +}); diff --git a/src/features/browser/model/browserAgent.ts b/src/features/browser/model/browserAgent.ts new file mode 100644 index 0000000000..979c0793b7 --- /dev/null +++ b/src/features/browser/model/browserAgent.ts @@ -0,0 +1,473 @@ +import { + browserHistory, + closeBrowserView, + evalInBrowser, + screenshotBrowser, +} from "../../../platform/tauri/browser"; +import { loadAgentBrowser } from "../../settings/model/displayPrefs"; +import type { BrowserTabSource } from "../../workspace/model/layout"; +import { + closeBrowserTab, + dockOfTab, + loadGeneration, + settleLoad, + findBrowserDock, + getBrowserState, + openBrowserTab, + patchBrowserTab, + selectBrowserTab, + subscribeBrowser, + touchAgentTab, +} from "./browserStore"; +import { + clickScript, + consoleScript, + readScript, + typeScript, + waitScript, +} from "./browserScripts"; +import { browserUrlFromInput, isBrowsableUrl } from "./browserUrl"; +import { isNativeTabReady, whenNativeTabReady } from "./nativeTabs"; +import { navigateBrowserTab } from "./browserNavigation"; + +type Input = Record; + +const LOAD_TIMEOUT_MS = 15_000; +const EVAL_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 25_000; + +function text(input: Input, key: string): string | undefined { + const value = input[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new Error(`${key} must be a string`); + return value; +} + +function required(input: Input, key: string): string { + const value = text(input, key); + if (!value) throw new Error(`${key} is required`); + return value; +} + +function bool(input: Input, key: string): boolean | undefined { + const value = input[key]; + return typeof value === "boolean" ? value : undefined; +} + +function clampNumber( + input: Input, + key: string, + fallback: number, + min: number, + max: number, +): number { + const value = input[key]; + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.round(value))); +} + +function tabSource(id: string): BrowserTabSource | undefined { + return dockOfTab(getBrowserState(), id)?.pane.files.find( + (file) => file.id === id, + )?.browser; +} + +function summary(id: string) { + const tab = tabSource(id); + return { tabId: id, url: tab?.url, title: tab?.title || undefined }; +} + +function pageUrl(input: Input): string { + const url = browserUrlFromInput(required(input, "url")); + if (!isBrowsableUrl(url)) throw new Error("Only http(s) pages can be opened"); + return url; +} + +type LoadResult = { timedOut?: true }; + +type LoadOptions = { + startWithinMs?: number; + timeoutMs?: number; + /** + * The load generation before an explicit document navigation, open or + * reload. A load must then start and finish: a missing start is not success, + * so the wait ends `timedOut` instead of passing early. + */ + since?: number; + /** + * With `since`: the navigation may not load at all, so a load that does not + * start within `startWithinMs` ends the wait without a timeout. + */ + maybeLoads?: boolean; +}; + +/** + * Resolve once a load finishes. Without `since`, a load that never starts + * within `startWithinMs` resolves right away (a hash change, a click that did + * not navigate, a reused view that already finished). With `since`, the load + * is awaited for the whole timeout. Rejects if the tab goes away and reports + * `timedOut` if the load outlasts `timeoutMs`. + */ +function waitForLoad( + id: string, + { + startWithinMs = 1_500, + timeoutMs = LOAD_TIMEOUT_MS, + since, + maybeLoads = false, + }: LoadOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const sawLoad = () => since !== undefined && loadGeneration(id) > since; + let started = !!tabSource(id)?.loading || sawLoad(); + let unsubscribe = () => {}; + const finish = (result: LoadResult, closed = false) => { + unsubscribe(); + clearTimeout(startTimer); + clearTimeout(timer); + if (closed) reject(new Error("The browser tab was closed")); + else resolve(result); + }; + const check = () => { + const tab = tabSource(id); + if (!tab) return finish({}, true); + if (tab.loading || sawLoad()) started = true; + if (started && !tab.loading) finish({}); + }; + const startTimer = + since === undefined || maybeLoads + ? setTimeout(() => { + if (!started) finish({}); + }, startWithinMs) + : undefined; + const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs); + unsubscribe = subscribeBrowser(check); + check(); + }); +} + +/** + * Resolve when the tab's native page exists. Rejects if the tab is closed or + * its creation reports an error, rather than waiting out the timeout. + */ +function waitForNative(id: string, timeoutMs = 8_000): Promise { + const abort = new AbortController(); + patchBrowserTab(id, { error: undefined }); + const check = () => { + const tab = tabSource(id); + if (!tab) abort.abort(new Error("The browser tab was closed")); + else if (tab.error) { + abort.abort(new Error(tab.error)); + } + }; + const unsubscribe = subscribeBrowser(check); + check(); + return whenNativeTabReady(id, timeoutMs, abort.signal).finally(unsubscribe); +} + +/** + * A native Started can arrive without its Finished (a stopped or superseded + * load), which would leave `loading` set forever. When a wait times out, ask + * the page itself. Policy: a parsed document ("interactive" or "complete") + * counts as usable, so the stale flag is cleared; subresources may still be + * loading. The reply is applied only if the tab still exists and no newer load + * started while the probe was in flight. + */ +async function reconcileLoad( + id: string, + load: LoadResult, + since?: number, +): Promise { + if (!load.timedOut) return load; + const generation = loadGeneration(id); + // An explicit navigation that never started a load has no stale flag to + // clear, and the old document's readyState says nothing about it. + if (since !== undefined && generation === since && !tabSource(id)?.loading) { + return load; + } + let state: unknown; + try { + state = await evalInBrowser(id, "document.readyState", 2_000); + } catch { + // An unresponsive page really is still loading, unless it went away. + } + if (!tabSource(id)) throw new Error("The browser tab was closed"); + if ( + (state === "complete" || state === "interactive") && + settleLoad(id, generation) + ) { + return {}; + } + return load; +} + +/** Wait for a load, then settle a timeout against the page's real state. */ +async function waitForPage( + id: string, + options?: LoadOptions, +): Promise { + return reconcileLoad(id, await waitForLoad(id, options), options?.since); +} + +/** Throw unless a wait ended with the page loaded. */ +function assertLoaded(load: LoadResult) { + if (load.timedOut) { + throw new Error( + "The page is still loading. Try again shortly, or use browser_wait.", + ); + } +} + +/** Calls that need the same tab live share one wait. */ +const readying = new Map>(); + +/** + * Make a tab's page usable: create it if suspended, then wait out any load. + * Concurrent callers share the in-flight wait instead of racing past it. + */ +function ensureLive(id: string): Promise { + const pending = readying.get(id); + if (pending) return pending; + const work = (async () => { + if (!isNativeTabReady(id)) { + // Resuming creates the webview, which then loads the saved URL. + await waitForNative(id); + assertLoaded(await waitForPage(id, { startWithinMs: 3_000 })); + } else if (tabSource(id)?.loading) { + assertLoaded(await waitForPage(id)); + } + })().finally(() => { + if (readying.get(id) === work) readying.delete(id); + }); + readying.set(id, work); + return work; +} + +/** Run browser tools for one session against its own tabs only. */ +export function createBrowserAgent(sessionId: string) { + const dock = () => findBrowserDock(getBrowserState().docks, sessionId); + + /** The tab a call acts on, made live (resumed if suspended). */ + const useTab = async (input: Input): Promise => { + const requested = text(input, "tabId"); + const current = dock(); + const id = requested ?? current?.pane.activeFileId; + if (!id || !current?.pane.files.some((file) => file.id === id)) { + throw new Error( + requested + ? `No tab ${requested} in this session. Call browser_tabs to list them.` + : "This session has no browser tab yet. Use browser_open first.", + ); + } + touchAgentTab(id); + await ensureLive(id); + return id; + }; + + const run = async (id: string, script: string, timeoutMs = EVAL_TIMEOUT_MS) => + evalInBrowser(id, script, timeoutMs); + + /** Click and type may navigate away before their result comes back. */ + const runAction = async (id: string, script: string) => { + let result: unknown; + try { + result = await run(id, script); + } catch (error) { + if (!String(error).includes("navigated away")) throw error; + result = { navigated: true }; + } + const load = await waitForPage(id); + return { ...(result as object), page: summary(id), ...load }; + }; + + const actions: Record Promise> = { + async browser_tabs() { + const current = dock(); + return { + panelOpen: !!current?.open, + tabs: (current?.pane.files ?? []).map((file) => ({ + tabId: file.id, + url: file.browser?.url, + title: file.browser?.title || undefined, + active: file.id === current?.pane.activeFileId, + loading: !!file.browser?.loading, + })), + }; + }, + + async browser_open(input) { + const url = pageUrl(input); + const id = openBrowserTab(url, { sessionId, background: true }); + if (!id) throw new Error("This session cannot open a browser tab"); + touchAgentTab(id); + const since = loadGeneration(id); + await waitForNative(id); + const load = await waitForPage(id, { since }); + return { ...summary(id), ...load }; + }, + + async browser_navigate(input) { + const id = await useTab(input); + const url = pageUrl(input); + // A fragment-only change stays in the document and emits no load; the + // page itself says whether this one did. The baseline is the one taken + // when the navigation was dispatched, not when it was requested. + const { outcome, since, overlapped } = await navigateBrowserTab(id, url); + const load = await waitForPage( + id, + outcome === "document" + ? { since } + : outcome === "unknown" + ? { since, maybeLoads: true } + : undefined, + ); + return { + ...summary(id), + ...load, + ...(overlapped + ? { + warning: + "An earlier navigation of this tab had not started loading; this result may reflect it instead.", + } + : {}), + }; + }, + + async browser_history(input) { + const action = required(input, "action"); + if (action !== "back" && action !== "forward" && action !== "reload") { + throw new Error("action must be back, forward, or reload"); + } + const id = await useTab(input); + const since = loadGeneration(id); + await browserHistory(id, action); + // Back and forward may stay in the document; only a reload must load. + const load = await waitForPage( + id, + action === "reload" ? { since } : undefined, + ); + return { ...summary(id), ...load }; + }, + + async browser_select(input) { + const id = await useTab({ tabId: required(input, "tabId") }); + selectBrowserTab(id); + return summary(id); + }, + + async browser_close(input) { + const id = required(input, "tabId"); + if (!dock()?.pane.files.some((file) => file.id === id)) { + throw new Error(`No tab ${id} in this session`); + } + closeBrowserTab(id); + await closeBrowserView(id).catch(() => undefined); + return { closed: id }; + }, + + async browser_read(input) { + const format = text(input, "format"); + if (format && format !== "text" && format !== "html") { + throw new Error("format must be text or html"); + } + const id = await useTab(input); + return run( + id, + readScript({ + format: format as "text" | "html" | undefined, + selector: text(input, "selector"), + maxChars: clampNumber(input, "maxChars", 20_000, 100, 200_000), + }), + ); + }, + + async browser_eval(input) { + const script = required(input, "script"); + const id = await useTab(input); + const timeoutMs = clampNumber( + input, + "timeoutMs", + EVAL_TIMEOUT_MS, + 100, + MAX_TIMEOUT_MS, + ); + return { result: await run(id, script, timeoutMs) }; + }, + + async browser_console(input) { + const id = await useTab(input); + return run( + id, + consoleScript({ + limit: clampNumber(input, "limit", 100, 1, 500), + clear: bool(input, "clear"), + }), + ); + }, + + async browser_click(input) { + const selector = text(input, "selector"); + const label = text(input, "text"); + if (!selector && !label) throw new Error("Pass a selector or text"); + const id = await useTab(input); + return runAction(id, clickScript({ selector, text: label })); + }, + + async browser_type(input) { + const id = await useTab(input); + return runAction( + id, + typeScript({ + selector: required(input, "selector"), + text: typeof input.text === "string" ? input.text : "", + clear: bool(input, "clear"), + submit: bool(input, "submit"), + }), + ); + }, + + async browser_wait(input) { + const selector = text(input, "selector"); + const label = text(input, "text"); + if (!selector && !label) throw new Error("Pass a selector or text"); + const timeoutMs = clampNumber(input, "timeoutMs", 10_000, 100, 24_000); + const id = await useTab(input); + return run( + id, + waitScript({ selector, text: label, timeoutMs }), + timeoutMs + 1_000, + ); + }, + + async browser_screenshot(input) { + const id = await useTab(input); + const image = await screenshotBrowser(id); + const page = summary(id); + return { + image, + mimeType: "image/png", + text: `${page.title ?? ""} ${page.url ?? ""}`.trim(), + }; + }, + }; + + return async (action: string, input: Input): Promise => { + if (!loadAgentBrowser()) { + throw new Error( + "Browser access for agents is turned off in MonoCode settings.", + ); + } + const handler = actions[action]; + if (!handler) throw new Error(`Unknown browser tool: ${action}`); + return handler(input ?? {}); + }; +} + +/** Handle one `browser` namespace control request. */ +export function handleBrowserRequest( + sessionId: string, + action: string, + input: Input, +): Promise { + return createBrowserAgent(sessionId)(action, input); +} diff --git a/src/features/browser/model/browserMcp.test.ts b/src/features/browser/model/browserMcp.test.ts new file mode 100644 index 0000000000..66ff7bd7b5 --- /dev/null +++ b/src/features/browser/model/browserMcp.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + acpBrowserMcpServers, + claudeBrowserMcpConfig, + codexBrowserMcpArgs, +} from "./browserMcp"; +import { buildClaudeSpawnArgs } from "../../../integrations/harness/providers/claude/claudeProtocol"; + +const launch = { + command: "/Applications/MonoCode.app/Contents/MacOS/monocode", + args: ["browser-mcp"], + env: { + MONOCODE_APP_ENDPOINT: "127.0.0.1:5000", + MONOCODE_APP_TOKEN: "secret", + }, +}; + +describe("browser MCP launch", () => { + it("gives Claude a stdio server without the credential", () => { + const config = claudeBrowserMcpConfig(launch); + expect(JSON.parse(config)).toEqual({ + mcpServers: { + monocode_browser: { + type: "stdio", + command: launch.command, + args: ["browser-mcp"], + }, + }, + }); + expect(config).not.toContain("secret"); + }); + + it("adds Claude's config only to interactive sessions", () => { + const config = claudeBrowserMcpConfig(launch); + const args = buildClaudeSpawnArgs({ mcpConfig: config }); + expect(args).toContain("--mcp-config"); + expect(args).not.toContain("--strict-mcp-config"); + const isolated = buildClaudeSpawnArgs({ + isolated: true, + mcpConfig: config, + }); + expect(isolated.filter((arg) => arg === config)).toEqual([]); + }); + + it("names the credential variables Codex should forward", () => { + const args = codexBrowserMcpArgs({ + ...launch, + command: "C:\\Program Files\\MonoCode\\monocode.exe", + }); + expect(args).toEqual([ + "-c", + 'mcp_servers.monocode_browser.command="C:\\\\Program Files\\\\MonoCode\\\\monocode.exe"', + "-c", + 'mcp_servers.monocode_browser.args=["browser-mcp"]', + "-c", + 'mcp_servers.monocode_browser.env_vars=["MONOCODE_APP_ENDPOINT","MONOCODE_APP_TOKEN"]', + ]); + expect(args.join(" ")).not.toContain("secret"); + }); + + it("passes ACP agents the credential over stdio", () => { + expect(acpBrowserMcpServers(null)).toEqual([]); + expect(acpBrowserMcpServers(launch)).toEqual([ + { + name: "monocode_browser", + command: launch.command, + args: ["browser-mcp"], + env: [ + { name: "MONOCODE_APP_ENDPOINT", value: "127.0.0.1:5000" }, + { name: "MONOCODE_APP_TOKEN", value: "secret" }, + ], + }, + ]); + }); +}); diff --git a/src/features/browser/model/browserMcp.ts b/src/features/browser/model/browserMcp.ts new file mode 100644 index 0000000000..85bfcb131c --- /dev/null +++ b/src/features/browser/model/browserMcp.ts @@ -0,0 +1,89 @@ +import { + browserMcpLaunch, + type BrowserMcpLaunch, +} from "../../../platform/tauri/browser"; +import { loadAgentBrowser } from "../../settings/model/displayPrefs"; + +/** MCP server name agents see; tools appear as browser_* under it. */ +export const BROWSER_MCP_NAME = "monocode_browser"; + +/** Context supplied each turn, including when an older provider thread resumes. */ +export const BROWSER_AGENT_CONTEXT = ` +You are running inside MonoCode. This chat's embedded, built-in, in-app browser is provided by the monocode_browser MCP server. For requests to open a page in your embedded browser, search a website, click links, read pages, take screenshots, or evaluate JavaScript there, use its browser_* tools. Discover those tools if they are deferred. Start with browser_open for a new page or browser_tabs for existing pages; use browser_read, browser_type, browser_click, browser_eval, and browser_screenshot as needed. +MonoCode's browser is separate from Computer Use's iab browser. An unavailable iab or an empty Computer Use browser inventory does not mean MonoCode's browser is unavailable. Try the monocode_browser tools before reporting that it is unavailable. Users do not need to name tools. Honor an explicit request to use another browser. The browser can run while its panel is hidden. +`; + +/** Session credential variables the server reads; set on every provider child. */ +const CREDENTIAL_ENV = ["MONOCODE_APP_ENDPOINT", "MONOCODE_APP_TOKEN"]; + +/** + * How this session's provider should start the browser MCP server, or null + * when agents may not use the browser. Call after the turn is authorized: + * that is when the session's credential exists. + */ +export async function browserMcpFor( + sessionId: string, +): Promise { + // What `isTauri()` reads, without importing it: provider tests mock that + // module without it. + const tauri = !!(globalThis as { isTauri?: boolean }).isTauri; + if (!tauri || !loadAgentBrowser()) return null; + return browserMcpLaunch(sessionId).catch(() => null); +} + +/** + * Claude Code `--mcp-config`. The provider child already carries the session + * credential and passes its environment to stdio servers, so it stays out of + * the arguments. + */ +export function claudeBrowserMcpConfig(launch: BrowserMcpLaunch): string { + return JSON.stringify({ + mcpServers: { + [BROWSER_MCP_NAME]: { + type: "stdio", + command: launch.command, + args: launch.args, + }, + }, + }); +} + +/** + * Codex `-c` overrides. Codex gives MCP servers a filtered environment, so + * name the credential variables to forward instead of writing their values. + */ +export function codexBrowserMcpArgs(launch: BrowserMcpLaunch): string[] { + const key = `mcp_servers.${BROWSER_MCP_NAME}`; + // JSON strings and arrays are valid TOML values. + return [ + "-c", + `${key}.command=${JSON.stringify(launch.command)}`, + "-c", + `${key}.args=${JSON.stringify(launch.args)}`, + "-c", + `${key}.env_vars=${JSON.stringify(CREDENTIAL_ENV)}`, + ]; +} + +/** ACP `mcpServers` for session/new and session/load. */ +export function acpBrowserMcpServers(launch: BrowserMcpLaunch | null): Array<{ + name: string; + command: string; + args: string[]; + env: Array<{ name: string; value: string }>; +}> { + if (!launch) return []; + return [ + { + name: BROWSER_MCP_NAME, + command: launch.command, + args: launch.args, + env: Object.entries(launch.env).map(([name, value]) => ({ name, value })), + }, + ]; +} + +/** ACP `mcpServers` for a session, empty when agents may not use the browser. */ +export async function acpBrowserMcpServersFor(sessionId: string) { + return acpBrowserMcpServers(await browserMcpFor(sessionId)); +} diff --git a/src/features/browser/model/browserNavigation.queue.test.ts b/src/features/browser/model/browserNavigation.queue.test.ts new file mode 100644 index 0000000000..93012c2746 --- /dev/null +++ b/src/features/browser/model/browserNavigation.queue.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Only the Tauri bridge is faked: the per-tab dispatch queue is the real one. +const sent: Array<{ + url: string; + resolve: (reply: { outcome: string; url?: string }) => void; +}> = []; +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn( + (_command: string, args: { url: string }) => + new Promise((resolve) => sent.push({ url: args.url, resolve })), + ), +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +import { + loadGeneration, + noteLoadStarted, + openBrowserTab, + patchBrowserTab, +} from "./browserStore"; +import { + DISPATCH_GATE_MS, + isNavigating, + navigateBrowserTab, +} from "./browserNavigation"; + +/** A native load start, reported the way the browser event handler does. */ +const loadStarts = (id: string) => { + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("navigateBrowserTab through the platform queue", () => { + beforeEach(() => { + sent.length = 0; + }); + + it("takes each baseline when the request is dispatched, not when it is queued", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "queue-s" })!; + const a = navigateBrowserTab(id, "https://a.test/"); + const b = navigateBrowserTab(id, "https://b.test/"); + await flush(); + // Only A is with the native side; B waits its turn. + expect(sent.map((call) => call.url)).toEqual(["https://a.test/"]); + const queuedAt = loadGeneration(id); + // A's load starts while B still waits, then A's request is answered. + noteLoadStarted(id); + sent[0].resolve({ outcome: "document" }); + await a; + await flush(); + expect(sent).toHaveLength(2); + sent[1].resolve({ outcome: "document" }); + const reply = await b; + // B's baseline already includes A's load, so that load is not B's... + expect(reply.since).toBe(queuedAt + 1); + // ...and B, whose own load has not started, stays protected from polling. + expect(isNavigating(id)).toBe(true); + noteLoadStarted(id); + expect(isNavigating(id)).toBe(false); + }); + + it("holds the next navigation until the previous one's load starts", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "queue-s2" })!; + patchBrowserTab(id, { loading: false }); + const a = navigateBrowserTab(id, "https://a.test/"); + const b = navigateBrowserTab(id, "https://b.test/"); + await flush(); + // The webview accepts A before A's load starts: the usual order. + sent[0].resolve({ outcome: "document" }); + await a; + await flush(); + expect(sent).toHaveLength(1); + const beforeA = loadGeneration(id); + loadStarts(id); + await flush(); + expect(sent.map((call) => call.url)).toEqual([ + "https://a.test/", + "https://b.test/", + ]); + sent[1].resolve({ outcome: "document" }); + const reply = await b; + expect(reply).toMatchObject({ since: beforeA + 1, overlapped: false }); + expect(isNavigating(id)).toBe(true); + }); + + it("dispatches anyway, flagged as overlapped, when the previous load never starts", async () => { + vi.useFakeTimers(); + try { + const id = openBrowserTab("https://old.test/", { + sessionId: "queue-s3", + })!; + patchBrowserTab(id, { loading: false }); + const a = navigateBrowserTab(id, "https://a.test/"); + const b = navigateBrowserTab(id, "https://b.test/"); + await vi.advanceTimersByTimeAsync(0); + sent[0].resolve({ outcome: "document" }); + await a; + await vi.advanceTimersByTimeAsync(DISPATCH_GATE_MS - 1); + expect(sent).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(sent).toHaveLength(2); + sent[1].resolve({ outcome: "document" }); + await expect(b).resolves.toMatchObject({ overlapped: true }); + } finally { + vi.useRealTimers(); + } + }); + + it("does not hold a navigation behind an applied one", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "queue-s4" })!; + const a = navigateBrowserTab(id, "https://old.test/#a"); + const b = navigateBrowserTab(id, "https://b.test/"); + await flush(); + sent[0].resolve({ outcome: "applied", url: "https://old.test/#a" }); + await a; + await flush(); + expect(sent).toHaveLength(2); + sent[1].resolve({ outcome: "document" }); + await expect(b).resolves.toMatchObject({ overlapped: false }); + }); +}); diff --git a/src/features/browser/model/browserNavigation.test.ts b/src/features/browser/model/browserNavigation.test.ts new file mode 100644 index 0000000000..c8dc24caf2 --- /dev/null +++ b/src/features/browser/model/browserNavigation.test.ts @@ -0,0 +1,620 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Reply = + { outcome: "applied" | "document" | "unknown"; url?: string } | undefined; +const pending: Array<{ + /** Answer like the native side; no outcome means a document navigation. */ + resolve: (outcome?: Reply) => void; + reject: (reason: unknown) => void; +}> = []; +let chains = 0; +vi.mock("../../../platform/tauri/browser", () => ({ + // Dispatches at once; the queue itself is covered in browserNavigation.queue.test.ts. + navigateBrowser: vi.fn( + (_id: string, _url: string, onDispatch?: () => void) => + new Promise((resolve, reject) => { + onDispatch?.(); + pending.push({ + resolve: (outcome) => resolve(outcome ?? { outcome: "document" }), + reject, + }); + }), + ), +})); + +import { + dockOfTab, + getBrowserState, + closeBrowserTab, + noteLoadFinished, + noteLoadStarted, + openBrowserTab, + patchBrowserTab, +} from "./browserStore"; +import { + DOCUMENT_SETTLE_MS, + UNKNOWN_SETTLE_MS, + isNavigating, + navigateBrowserTab, +} from "./browserNavigation"; +import { setNativeTabReady } from "./nativeTabs"; +import { syncNativeUrl } from "./nativeUrl"; + +/** A native URL read that finds the page at `url`. */ +const at = (url: string) => async () => url; + +const SAME = { outcome: "applied" } as const; + +const tab = (id: string) => + dockOfTab(getBrowserState(), id)?.pane.files.find((f) => f.id === id) + ?.browser; + +describe("navigateBrowserTab rollback", () => { + beforeEach(() => { + pending.length = 0; + }); + + it("rolls back a rejected navigation that is still current", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-1" })!; + const call = navigateBrowserTab(id, "https://new.test"); + pending[0].reject("blocked"); + await expect(call).rejects.toBe("blocked"); + expect(tab(id)).toMatchObject({ + url: "https://old.test", + loading: false, + error: "blocked", + }); + }); + + it("leaves a newer request alone when an older one rejects", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-2" })!; + const first = navigateBrowserTab(id, "https://a.test"); + const second = navigateBrowserTab(id, "https://b.test"); + pending[0].reject("late"); + await expect(first).rejects.toBe("late"); + expect(tab(id)).toMatchObject({ url: "https://b.test", error: undefined }); + pending[1].resolve(); + await second; + }); + + it("leaves a newer load start alone when the request rejects", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-3" })!; + const call = navigateBrowserTab(id, "https://a.test"); + noteLoadStarted(id); + patchBrowserTab(id, { url: "https://c.test", loading: true }); + pending[0].reject("late"); + await expect(call).rejects.toBe("late"); + expect(tab(id)).toMatchObject({ + url: "https://c.test", + loading: true, + error: undefined, + }); + }); + + it("restores the pre-navigation url when overlapping requests both reject", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-4" })!; + const first = navigateBrowserTab(id, "https://a.test"); + const second = navigateBrowserTab(id, "https://b.test"); + pending[0].reject("a"); + pending[1].reject("b"); + await expect(first).rejects.toBe("a"); + await expect(second).rejects.toBe("b"); + expect(tab(id)).toMatchObject({ url: "https://old.test", error: "b" }); + }); + + it("preserves an active load when navigation is rejected", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-5" })!; + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + const call = navigateBrowserTab(id, "https://a.test"); + pending[0].reject("blocked"); + await expect(call).rejects.toBe("blocked"); + expect(tab(id)).toMatchObject({ url: "https://old.test", loading: true }); + }); + + it("does not keep state for settled requests after the tab closes", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-6" })!; + const call = navigateBrowserTab(id, "https://a.test"); + pending[0].resolve(); + await call; + const again = navigateBrowserTab(id, "https://b.test"); + pending[1].reject("x"); + await expect(again).rejects.toBe("x"); + // a fresh request after settlement captures the current url, not a stale one + expect(tab(id)?.url).toBe("https://a.test"); + }); + + it("rolls back to the native url when it changed before a later request rejects", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-7" })!; + const first = navigateBrowserTab(id, "https://a.test"); + noteLoadStarted(id); + patchBrowserTab(id, { url: "https://a.test", loading: true }); + const second = navigateBrowserTab(id, "https://b.test"); + pending[1].reject("b"); + await expect(second).rejects.toBe("b"); + expect(tab(id)).toMatchObject({ url: "https://a.test", error: "b" }); + pending[0].resolve(); + await first; + }); + + it("rolls back to an accepted navigation when a later overlapping one rejects", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-8" })!; + const first = navigateBrowserTab(id, "https://a.test"); + const second = navigateBrowserTab(id, "https://b.test"); + pending[0].resolve(); + await first; + pending[1].reject("b"); + await expect(second).rejects.toBe("b"); + expect(tab(id)).toMatchObject({ url: "https://a.test", error: "b" }); + }); + + it("does not restore loading after the native load finished", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-9" })!; + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + const call = navigateBrowserTab(id, "https://a.test"); + patchBrowserTab(id, { loading: false }); + pending[0].reject("blocked"); + await expect(call).rejects.toBe("blocked"); + expect(tab(id)).toMatchObject({ loading: false, error: "blocked" }); + }); + + it("keeps a redirected native url when a request rejects after the load finished", async () => { + const id = openBrowserTab("https://a.test", { sessionId: "nav-11" })!; + noteLoadStarted(id); + const call = navigateBrowserTab(id, "https://b.test"); + noteLoadFinished(id); + patchBrowserTab(id, { url: "https://c.test", loading: false }); + pending[0].reject("blocked"); + await expect(call).rejects.toBe("blocked"); + expect(tab(id)?.url).toBe("https://c.test"); + }); + + it("shows an older acceptance that arrives after a newer rejection", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-12" })!; + const first = navigateBrowserTab(id, "https://old.test#a"); + const second = navigateBrowserTab(id, "https://b.test"); + pending[1].reject("b"); + await expect(second).rejects.toBe("b"); + expect(tab(id)?.url).toBe("https://old.test"); + pending[0].resolve(); + await first; + expect(tab(id)?.url).toBe("https://old.test#a"); + }); + + it("does not let a late acceptance override a newer request or native event", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-13" })!; + const first = navigateBrowserTab(id, "https://a.test"); + const second = navigateBrowserTab(id, "https://b.test"); + pending[1].reject("b"); + await expect(second).rejects.toBe("b"); + const third = navigateBrowserTab(id, "https://c.test"); + pending[0].resolve(); + await first; + expect(tab(id)?.url).toBe("https://c.test"); + pending[2].resolve(); + await third; + + const id2 = openBrowserTab("https://old.test", { sessionId: "nav-13b" })!; + const a = navigateBrowserTab(id2, "https://a.test"); + const b = navigateBrowserTab(id2, "https://b.test"); + pending[4].reject("b"); + await expect(b).rejects.toBe("b"); + noteLoadFinished(id2); + patchBrowserTab(id2, { url: "https://z.test" }); + pending[3].resolve(); + await a; + expect(tab(id2)?.url).toBe("https://z.test"); + }); + + it("shows the highest acceptance after a newer rejection, in either order", async () => { + const run = async (order: [number, number], sessionId: string) => { + const id = openBrowserTab("https://old.test", { sessionId })!; + const base = pending.length; + const a = navigateBrowserTab(id, "https://old.test#a"); + const b = navigateBrowserTab(id, "https://old.test#b"); + const c = navigateBrowserTab(id, "https://c.test"); + pending[base + 2].reject("c"); + await expect(c).rejects.toBe("c"); + expect(tab(id)?.url).toBe("https://old.test"); + const calls = [a, b]; + for (const i of order) { + pending[base + i].resolve(); + await calls[i]; + } + return id; + }; + expect(tab(await run([0, 1], "nav-14a"))?.url).toBe("https://old.test#b"); + expect(tab(await run([1, 0], "nav-14b"))?.url).toBe("https://old.test#b"); + }); + + it("does not show an older acceptance after a native event or newer request", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-15" })!; + const base = pending.length; + const a = navigateBrowserTab(id, "https://a.test"); + const b = navigateBrowserTab(id, "https://b.test"); + const c = navigateBrowserTab(id, "https://c.test"); + pending[base + 2].reject("c"); + await expect(c).rejects.toBe("c"); + pending[base].resolve(); + await a; + expect(tab(id)?.url).toBe("https://a.test"); + noteLoadFinished(id); + patchBrowserTab(id, { url: "https://z.test" }); + pending[base + 1].resolve(); + await b; + expect(tab(id)?.url).toBe("https://z.test"); + }); + + it("tolerates the tab closing mid-request", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-10" })!; + const call = navigateBrowserTab(id, "https://a.test"); + closeBrowserTab(id); + pending[0].reject("gone"); + await expect(call).rejects.toBe("gone"); + expect(tab(id)).toBeUndefined(); + const reopened = navigateBrowserTab(id, "https://b.test"); + pending[1].resolve(); + await reopened; + }); + + it("reports whether the page applied the target itself", async () => { + const id = openBrowserTab("https://old.test", { sessionId: "nav-16" })!; + const hash = navigateBrowserTab(id, "https://old.test#a"); + pending[0].resolve(SAME); + await expect(hash).resolves.toMatchObject({ outcome: "applied" }); + const document = navigateBrowserTab(id, "https://new.test"); + pending[1].resolve(); + await expect(document).resolves.toMatchObject({ outcome: "document" }); + }); +}); + +describe("document navigation settling", () => { + beforeEach(() => { + pending.length = 0; + }); + + const ready = (url = "https://old.test/") => { + const id = openBrowserTab(url, { sessionId: `nav-s-${++chains}` })!; + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + return id; + }; + + it("keeps the optimistic url from being polled back before load start", async () => { + const id = ready(); + const call = navigateBrowserTab(id, "https://new.test/"); + pending[0].resolve(); + await call; + expect(isNavigating(id)).toBe(true); + // Native still reports the old document: the poll must not revert. + expect(await syncNativeUrl(id, at("https://old.test/"))).toBe(false); + expect(tab(id)?.url).toBe("https://new.test/"); + // Once a load starts, protection hands off to the loading flag. + noteLoadStarted(id); + expect(isNavigating(id)).toBe(false); + }); + + it("keeps protecting an accepted navigation when load start is delayed", async () => { + vi.useFakeTimers(); + try { + const id = ready(); + const call = navigateBrowserTab(id, "https://new.test/"); + pending[0].resolve(); + await call; + vi.advanceTimersByTime(DOCUMENT_SETTLE_MS - 1_000); + expect(isNavigating(id)).toBe(true); + expect(await syncNativeUrl(id, at("https://old.test/"))).toBe(false); + expect(tab(id)?.url).toBe("https://new.test/"); + } finally { + vi.useRealTimers(); + } + }); + + it("recovers polling when a document load never reports a start", async () => { + vi.useFakeTimers(); + try { + const id = ready(); + const call = navigateBrowserTab(id, "https://denied.test/"); + pending[0].resolve({ outcome: "document" }); + await call; + vi.advanceTimersByTime(DOCUMENT_SETTLE_MS); + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at("https://old.test/"))).toBe(true); + expect(tab(id)?.url).toBe("https://old.test/"); + } finally { + vi.useRealTimers(); + } + }); + + it("gives an unknown outcome only a short protection, then lets polling decide", async () => { + vi.useFakeTimers(); + try { + const id = ready(); + const call = navigateBrowserTab(id, "https://old.test/#x"); + pending[0].resolve({ outcome: "unknown" }); + await expect(call).resolves.toMatchObject({ outcome: "unknown" }); + expect(isNavigating(id)).toBe(true); + vi.advanceTimersByTime(UNKNOWN_SETTLE_MS); + expect(isNavigating(id)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("shows where the page's handlers left an applied navigation", async () => { + const id = ready(); + const call = navigateBrowserTab(id, "https://old.test/#b"); + pending[0].resolve({ outcome: "applied", url: "https://old.test/#c" }); + await call; + expect(isNavigating(id)).toBe(false); + expect(tab(id)?.url).toBe("https://old.test/#c"); + }); + + it("rolls a later rejection back to the rewritten url, not the applied target", async () => { + const id = ready(); + const applied = navigateBrowserTab(id, "https://old.test/#b"); + const rejected = navigateBrowserTab(id, "https://bad.test/"); + pending[0].resolve({ outcome: "applied", url: "https://old.test/#c" }); + await applied; + pending[1].reject("blocked"); + await expect(rejected).rejects.toBe("blocked"); + expect(tab(id)?.url).toBe("https://old.test/#c"); + }); + + it("treats a reply without an outcome as a document navigation", async () => { + const id = ready(); + const call = navigateBrowserTab(id, "https://old.test/#x"); + pending[0].resolve(undefined); + await call; + // Nothing says the page applied it, so only a load start can end this. + expect(isNavigating(id)).toBe(true); + expect(await syncNativeUrl(id, at("https://old.test/#x"))).toBe(false); + expect(isNavigating(id)).toBe(true); + noteLoadStarted(id); + expect(isNavigating(id)).toBe(false); + }); + + it("recovers polling after a lone rejected navigation", async () => { + const id = ready(); + const call = navigateBrowserTab(id, "https://new.test/"); + pending[0].reject("blocked"); + await expect(call).rejects.toBe("blocked"); + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at("https://old.test/#z"))).toBe(true); + }); + + it("keeps an accepted navigation protected when an overlapping one rejects", async () => { + const id = ready(); + const a = navigateBrowserTab(id, "https://a.test/"); + const b = navigateBrowserTab(id, "https://b.test/"); + pending[0].resolve(); + await a; + pending[1].reject("blocked"); + await expect(b).rejects.toBe("blocked"); + expect(isNavigating(id)).toBe(true); + expect(await syncNativeUrl(id, at("https://old.test/"))).toBe(false); + expect(tab(id)?.url).toBe("https://a.test/"); + }); + + it("drops settling state for tabs that close, including late replies", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "nav-c" })!; + const done = navigateBrowserTab(id, "https://a.test/"); + pending[0].resolve(); + await done; + expect(isNavigating(id)).toBe(true); + closeBrowserTab(id); + expect(isNavigating(id)).toBe(false); + + const id2 = openBrowserTab("https://old.test/", { sessionId: "nav-c2" })!; + const late = navigateBrowserTab(id2, "https://a.test/"); + closeBrowserTab(id2); + pending[1].resolve(); + await late; + expect(isNavigating(id2)).toBe(false); + }); + + it("does not protect a navigation whose load started before the reply", async () => { + const id = ready(); + const call = navigateBrowserTab(id, "https://a.test/"); + noteLoadStarted(id); + noteLoadFinished(id); + patchBrowserTab(id, { url: "https://b.test/", loading: false }); + pending[0].resolve(); + await call; + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at("https://b.test/#next"))).toBe(true); + expect(tab(id)?.url).toBe("https://b.test/#next"); + }); +}); + +describe("same-document navigation completion", () => { + beforeEach(() => { + pending.length = 0; + }); + + const A = "https://old.test/#a"; + const B = "https://old.test/#b"; + const C = "https://old.test/#c"; + + const chain = (seed: string, ...targets: string[]) => { + const id = openBrowserTab(seed, { sessionId: `nav-chain-${++chains}` })!; + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + return { + id, + calls: targets.map((url) => navigateBrowserTab(id, url)), + base: pending.length - targets.length, + }; + }; + + /** + * The native side applies each fragment in the page before it replies and + * takes requests one at a time, so the page ends at the last target whatever + * order the replies arrive in. Every read taken after the last reply, fresh + * and carrying nothing but the page's URL, sees that outcome. + */ + const permutations = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + + it.each(permutations)( + "settles A,B,A on the page's URL whatever the reply order (%i/%i/%i)", + async (...order) => { + const { id, calls, base } = chain("https://old.test/", A, B, A); + // Polls while a reply is outstanding are held off, whatever they read. + for (const [step, i] of order.entries()) { + for (const url of ["https://old.test/", A, B]) { + expect(await syncNativeUrl(id, at(url))).toBe(false); + } + expect(tab(id)?.url).toBe(A); + expect(isNavigating(id)).toBe(true); + pending[base + i].resolve(SAME); + await calls[i]; + expect(isNavigating(id)).toBe(step < order.length - 1); + } + // Every later read is fresh. The page is at A, and later moves show up. + for (let i = 0; i < 3; i++) { + expect(await syncNativeUrl(id, at(A))).toBe(false); + expect(isNavigating(id)).toBe(false); + } + expect(tab(id)?.url).toBe(A); + expect(await syncNativeUrl(id, at(C))).toBe(true); + expect(tab(id)?.url).toBe(C); + }, + ); + + const longOrders = [ + [0, 1, 2, 3, 4], + [4, 3, 2, 1, 0], + [2, 0, 4, 1, 3], + [1, 3, 0, 4, 2], + [4, 0, 3, 1, 2], + ]; + + it.each(longOrders)( + "settles A,B,A,C,A on the page's URL whatever the reply order (%i/%i/%i/%i/%i)", + async (...order) => { + const { id, calls, base } = chain("https://old.test/", A, B, A, C, A); + for (const i of order) { + expect(await syncNativeUrl(id, at(C))).toBe(false); + expect(tab(id)?.url).toBe(A); + pending[base + i].resolve(SAME); + await calls[i]; + } + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at(A))).toBe(false); + expect(tab(id)?.url).toBe(A); + expect(await syncNativeUrl(id, at(B))).toBe(true); + expect(tab(id)?.url).toBe(B); + }, + ); + + it("follows a page that rewrites the hash it was sent to", async () => { + const { id, calls, base } = chain("https://old.test/", B); + pending[base].resolve(SAME); + await calls[0]; + // The page's hashchange handler replaced the URL before the first poll. + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at(C))).toBe(true); + expect(tab(id)?.url).toBe(C); + }); + + it("follows a page that rewrites an earlier hash in a longer chain", async () => { + const { id, calls, base } = chain("https://old.test/", A, B); + pending[base + 1].resolve(SAME); + pending[base].resolve(SAME); + await Promise.all(calls); + expect(await syncNativeUrl(id, at(C))).toBe(true); + expect(tab(id)?.url).toBe(C); + }); + + it("settles a no-op navigation to the URL the page is already at", async () => { + const { id, calls, base } = chain(A, A); + expect(isNavigating(id)).toBe(true); + pending[base].resolve(SAME); + await calls[0]; + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at(A))).toBe(false); + expect(await syncNativeUrl(id, at(C))).toBe(true); + }); + + it("settles a navigation to the URL a tab was seeded with", async () => { + const { id, calls, base } = chain(A, B, A); + pending[base].resolve(SAME); + pending[base + 1].resolve(SAME); + await Promise.all(calls); + expect(isNavigating(id)).toBe(false); + for (let i = 0; i < 3; i++) { + expect(await syncNativeUrl(id, at(A))).toBe(false); + } + expect(tab(id)?.url).toBe(A); + expect(await syncNativeUrl(id, at(C))).toBe(true); + }); + + it("drops a read that began before the navigation", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "nav-v" })!; + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + let release!: (url: string) => void; + const stale = syncNativeUrl( + id, + () => new Promise((resolve) => (release = resolve)), + ); + const call = navigateBrowserTab(id, A); + pending[0].resolve(SAME); + await call; + // The read sampled the page before the fragment was applied. + release("https://old.test/"); + expect(await stale).toBe(false); + expect(tab(id)?.url).toBe(A); + }); + + it("lets a completed fragment supersede an older document request", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "nav-x" })!; + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + const doc = navigateBrowserTab(id, "https://elsewhere.test/"); + pending[0].resolve(); + await doc; + expect(isNavigating(id)).toBe(true); + const hash = navigateBrowserTab(id, A); + pending[1].resolve(SAME); + await hash; + // The page navigated away from what the document request was waiting for. + expect(isNavigating(id)).toBe(false); + expect(await syncNativeUrl(id, at(A))).toBe(false); + expect(await syncNativeUrl(id, at(C))).toBe(true); + }); + + it("keeps a newer document request protected when an older fragment completes", async () => { + const id = openBrowserTab("https://old.test/", { sessionId: "nav-y" })!; + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + const hash = navigateBrowserTab(id, A); + const doc = navigateBrowserTab(id, "https://elsewhere.test/"); + pending[1].resolve(); + await doc; + pending[0].resolve(SAME); + await hash; + expect(isNavigating(id)).toBe(true); + expect(await syncNativeUrl(id, at(A))).toBe(false); + expect(tab(id)?.url).toBe("https://elsewhere.test/"); + noteLoadStarted(id); + expect(isNavigating(id)).toBe(false); + }); + + it("recovers polling when a fragment request is rejected", async () => { + const { id, calls, base } = chain("https://old.test/", A); + pending[base].reject("no webview"); + await expect(calls[0]).rejects.toBe("no webview"); + expect(isNavigating(id)).toBe(false); + expect(tab(id)?.url).toBe("https://old.test/"); + expect(await syncNativeUrl(id, at(C))).toBe(true); + }); +}); diff --git a/src/features/browser/model/browserNavigation.ts b/src/features/browser/model/browserNavigation.ts new file mode 100644 index 0000000000..6912a73e88 --- /dev/null +++ b/src/features/browser/model/browserNavigation.ts @@ -0,0 +1,268 @@ +import { + navigateBrowser, + type BrowserNavigation, +} from "../../../platform/tauri/browser"; +import { + dockOfTab, + getBrowserState, + loadGeneration, + nativeUrlEpoch, + patchBrowserTab, + subscribeBrowser, +} from "./browserStore"; + +interface Inflight { + latest: number; + count: number; + /** Rollback baseline: the URL before the unsettled requests, kept current. */ + url?: string; + /** Native URL epoch the baseline was taken at. */ + epoch: number; + /** Highest request number accepted so far. */ + accepted: number; + /** Request whose rejection last restored the baseline, until superseded. */ + restoredBy?: number; + restoredEpoch?: number; +} + +const inflight = new Map(); + +interface Settling { + /** Load generation when the request was dispatched natively, not queued. */ + generation: number; + /** Order the request was made in, across every request of every tab. */ + order: number; + /** When to stop waiting for a load that may never report itself. */ + expires: number; +} + +/** + * A load that fails before it commits or is denied can report no event, and so + * can a navigation whose outcome is unknown, so waiting for a load start is + * bounded. A document load is given long enough that a slow start does not let + * polling roll the URL back; an unknown outcome was never promised a load. + */ +export const DOCUMENT_SETTLE_MS = 30_000; +export const UNKNOWN_SETTLE_MS = 3_000; + +/** + * How long a navigation waits, before it is dispatched, for an earlier one of + * its tab to start loading. The webview accepts a navigation before its load + * starts, so without the wait the earlier load would count as the later one's. + */ +export const DISPATCH_GATE_MS = 2_000; + +let requestOrder = 0; + +/** + * Navigations the webview accepted that have not started loading. The IPC + * resolves before the load event, so until a load starts the native URL may + * still be the old document's. An entry ends when a load starts or, as the + * recovery for loads that never report one, when it expires. + * + * Applied navigations never get an entry: the page settled them before the + * reply, so a read after the reply already shows their outcome. One also + * supersedes the older entries: the page navigated away from + * whatever they were waiting for, and a load that does start anyway reports + * itself. Only handlers that run synchronously are accounted for; a page that + * changes its URL later is seen by polling like any other same-document change. + */ +const settling = new Map>(); + +function clearSettling(id: string) { + settling.delete(id); +} + +/** Drop entries of tabs that left the store, and of tabs whose load started. */ +function pruneSettling(id?: string) { + const live = getBrowserState(); + for (const key of [...settling.keys()]) { + if (id !== undefined && key !== id) { + if (!dockOfTab(live, key)) clearSettling(key); + continue; + } + const entries = settling.get(key); + if (!entries || !dockOfTab(live, key)) { + clearSettling(key); + continue; + } + const now = Date.now(); + for (const entry of entries) { + if (loadGeneration(key) !== entry.generation || now >= entry.expires) { + entries.delete(entry); + } + } + if (entries.size === 0) clearSettling(key); + } +} + +/** Whether a navigation request for the tab is unsettled or awaiting its load. */ +export function isNavigating(id: string): boolean { + if (inflight.has(id)) return true; + pruneSettling(id); + return settling.has(id); +} + +/** A same-document navigation completed: older waiting entries are superseded. */ +function supersede(id: string, order: number) { + const entries = settling.get(id); + if (!entries) return; + for (const entry of entries) { + if (entry.order < order) entries.delete(entry); + } + if (entries.size === 0) clearSettling(id); +} + +/** Whether an earlier request of the tab still waits for its load to start. */ +function hasEarlierSettling(id: string, order: number): boolean { + pruneSettling(id); + return [...(settling.get(id) ?? [])].some((entry) => entry.order < order); +} + +/** + * Resolve once no earlier request of the tab waits for its load to start, or + * after `DISPATCH_GATE_MS`. Resolves with whether the earlier ones cleared. + */ +function earlierLoadsStarted(id: string, order: number): Promise { + return new Promise((resolve) => { + const finish = (cleared: boolean) => { + unsubscribe(); + clearTimeout(timer); + resolve(cleared); + }; + // Load starts are reported through the store, with the loading flag. + const unsubscribe = subscribeBrowser(() => { + if (!hasEarlierSettling(id, order)) finish(true); + }); + const timer = setTimeout( + () => finish(!hasEarlierSettling(id, order)), + DISPATCH_GATE_MS, + ); + }); +} + +export type TabNavigation = { + outcome: BrowserNavigation["outcome"]; + /** + * The tab's load generation when this navigation was dispatched natively. A + * load counted after it may belong to this navigation; one before it cannot. + */ + since: number; + /** + * An earlier navigation of the tab had still not started loading when this + * one was dispatched, so a load counted after `since` may be that one's. + */ + overlapped: boolean; +}; + +/** + * Native load events own loading: applied and rejected navigations emit none. + * Resolves with what the native side knows of the outcome. + */ +export async function navigateBrowserTab( + id: string, + url: string, +): Promise { + const browser = () => + dockOfTab(getBrowserState(), id)?.pane.files.find((file) => file.id === id) + ?.browser; + pruneSettling(); + let entry = inflight.get(id); + if (!entry) { + entry = { + latest: 0, + count: 0, + url: browser()?.url, + epoch: nativeUrlEpoch(id), + accepted: 0, + }; + inflight.set(id, entry); + } else if (entry.epoch !== nativeUrlEpoch(id)) { + // Native load events reported a URL since the baseline; it is authoritative. + entry.url = browser()?.url; + entry.epoch = nativeUrlEpoch(id); + entry.restoredBy = undefined; + } + const state = entry; + // Whether this is still the newest request, on the epoch it was made in. + const current = () => + state.latest === request && + nativeUrlEpoch(id) === epoch && + !!dockOfTab(getBrowserState(), id); + state.count += 1; + const request = ++state.latest; + const order = ++requestOrder; + // Read again when the request's turn comes: earlier requests of the tab may + // start loads while this one waits in the queue, and those are not its own. + let generation = loadGeneration(id); + let overlapped = false; + const epoch = nativeUrlEpoch(id); + patchBrowserTab(id, { url, error: undefined }); + try { + const reply = await navigateBrowser(id, url, () => { + const take = (cleared: boolean) => { + generation = loadGeneration(id); + overlapped = !cleared; + }; + if (!hasEarlierSettling(id, order)) return take(true); + return earlierLoadsStarted(id, order).then(take); + }); + // An unreported outcome is a document navigation: its load events decide. + const outcome = reply?.outcome ?? "document"; + if (outcome === "applied") { + supersede(id, order); + } else if ( + (outcome === "document" || outcome === "unknown") && + dockOfTab(getBrowserState(), id) && + // A load that started since the request already took over from us. + loadGeneration(id) === generation + ) { + const entries = settling.get(id) ?? new Set(); + entries.add({ + generation, + order, + expires: + Date.now() + + (outcome === "document" ? DOCUMENT_SETTLE_MS : UNKNOWN_SETTLE_MS), + }); + settling.set(id, entries); + } + // Where the page's handlers left an applied navigation, else the target. + const landed = (outcome === "applied" && reply?.url) || url; + if (landed !== url && current()) patchBrowserTab(id, { url: landed }); + // Accepted, so a later rejection must not roll back past it. + if (state.epoch === epoch && request > state.accepted) { + state.accepted = request; + state.url = landed; + // A newer request already rejected and restored the older url; this + // acceptance is the latest word, so show it. Keep the restore marker so + // a still-newer acceptance can also update the display. + if ( + state.restoredBy !== undefined && + state.restoredBy > request && + state.latest === state.restoredBy && + state.restoredEpoch === epoch && + nativeUrlEpoch(id) === epoch && + browser() + ) { + patchBrowserTab(id, { url: landed }); + } + } + return { outcome, since: generation, overlapped }; + } catch (reason) { + if (current()) { + state.restoredBy = request; + state.restoredEpoch = epoch; + patchBrowserTab(id, { + error: String(reason), + // Only the optimistic loading from opening a tab is ours to clear. + ...(generation === 0 ? { loading: false } : {}), + ...(state.url ? { url: state.url } : {}), + }); + } + throw reason; + } finally { + state.count -= 1; + if (state.count === 0 && inflight.get(id) === state) inflight.delete(id); + } +} diff --git a/src/features/browser/model/browserScripts.test.ts b/src/features/browser/model/browserScripts.test.ts new file mode 100644 index 0000000000..e6a9a5e740 --- /dev/null +++ b/src/features/browser/model/browserScripts.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clickScript, + consoleScript, + readScript, + typeScript, + waitScript, +} from "./browserScripts"; + +// The native side runs each script as the body of an async function. +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new ( + body: string, +) => () => Promise; +const run = (script: string) => new AsyncFunction(script)(); + +beforeEach(() => { + document.body.innerHTML = ` +

Hello

World

+ + +
`; +}); + +describe("page scripts", () => { + it("reads text or HTML, whole page or one element, with truncation", async () => { + const page = (await run(readScript({}))) as { content: string }; + expect(page.content).toContain("Hello"); + const html = (await run( + readScript({ format: "html", selector: "h1" }), + )) as { + content: string; + }; + expect(html.content).toBe("

Hello

"); + const short = (await run( + readScript({ selector: "main", maxChars: 3 }), + )) as { + content: string; + truncated: boolean; + }; + expect(short).toMatchObject({ truncated: true }); + expect(short.content).toHaveLength(3); + await expect(run(readScript({ selector: "#missing" }))).rejects.toThrow( + "No element matches #missing", + ); + }); + + it("clicks by selector or by visible text", async () => { + vi.useFakeTimers(); + const clicked = vi.fn(); + document.getElementById("next")!.addEventListener("click", clicked); + const result = await run(clickScript({ text: "next page" })); + expect(result).toEqual({ clicked: 'a#next "Next page"' }); + vi.runAllTimers(); + expect(clicked).toHaveBeenCalledTimes(1); + await expect(run(clickScript({ selector: ".nope" }))).rejects.toThrow( + "No element matches .nope", + ); + vi.useRealTimers(); + }); + + it("types into inputs and selects, firing input and change", async () => { + const input = document.getElementById("q") as HTMLInputElement; + const events: string[] = []; + input.addEventListener("input", () => events.push("input")); + input.addEventListener("change", () => events.push("change")); + await run(typeScript({ selector: "#q", text: "new" })); + expect(input.value).toBe("new"); + await run(typeScript({ selector: "#q", text: "er", clear: false })); + expect(input.value).toBe("newer"); + expect(events).toEqual(["input", "change", "input", "change"]); + + await run(typeScript({ selector: "#size", text: "Large" })); + expect((document.getElementById("size") as HTMLSelectElement).value).toBe( + "l", + ); + await expect( + run(typeScript({ selector: "h1", text: "x" })), + ).rejects.toThrow("is not editable"); + }); + + it("waits for elements and text", async () => { + setTimeout(() => { + document.body.insertAdjacentHTML( + "beforeend", + "
Done!
", + ); + }, 50); + await expect( + run(waitScript({ selector: ".late", timeoutMs: 2000 })), + ).resolves.toMatchObject({ + found: true, + }); + await expect( + run(waitScript({ text: "Done!", timeoutMs: 500 })), + ).resolves.toMatchObject({ + found: true, + }); + await expect( + run(waitScript({ text: "never", timeoutMs: 150 })), + ).rejects.toThrow("Timed out"); + }); + + it("reads and clears captured console entries", async () => { + expect(await run(consoleScript({}))).toMatchObject({ entries: [] }); + (window as unknown as { __monocodeConsole: unknown[] }).__monocodeConsole = + [ + { level: "log", text: "a" }, + { level: "error", text: "b" }, + ]; + expect(await run(consoleScript({ limit: 1, clear: true }))).toEqual({ + entries: [{ level: "error", text: "b" }], + total: 2, + }); + expect(await run(consoleScript({}))).toEqual({ entries: [], total: 0 }); + }); + + it("embeds agent input as data, never as code", async () => { + const hostile = '"); document.body.innerHTML = "pwned"; ("'; + await expect(run(readScript({ selector: hostile }))).rejects.toThrow(); + expect(document.body.innerHTML).not.toContain("pwned"); + }); +}); diff --git a/src/features/browser/model/browserScripts.ts b/src/features/browser/model/browserScripts.ts new file mode 100644 index 0000000000..ff0e749603 --- /dev/null +++ b/src/features/browser/model/browserScripts.ts @@ -0,0 +1,168 @@ +/** + * Page scripts behind the agent browser tools. Each is the body of an async + * function run by `browser_eval`; inputs are embedded as JSON literals so no + * agent text is ever spliced into code. + */ + +const json = (value: unknown) => JSON.stringify(value ?? null); + +/** Describe an element briefly, for tool results. */ +const DESCRIBE = ` + function describe(el) { + var text = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim(); + if (text.length > 80) text = text.slice(0, 80) + "…"; + var tag = el.tagName.toLowerCase(); + var id = el.id ? "#" + el.id : ""; + return text ? tag + id + ' "' + text + '"' : tag + id; + }`; + +export function readScript(input: { + format?: "text" | "html"; + selector?: string; + maxChars?: number; +}): string { + return ` + var selector = ${json(input.selector)}; + var format = ${json(input.format ?? "text")}; + var max = ${json(input.maxChars ?? 20000)}; + var root = selector ? document.querySelector(selector) : document.documentElement; + if (!root) throw new Error("No element matches " + selector); + var content = format === "html" + ? root.outerHTML + : (root.innerText !== undefined ? root.innerText : root.textContent) || ""; + var total = content.length; + return { + url: location.href, + title: document.title, + content: total > max ? content.slice(0, max) : content, + truncated: total > max, + totalChars: total, + };`; +} + +export function consoleScript(input: { + limit?: number; + clear?: boolean; +}): string { + return ` + var entries = window.__monocodeConsole; + if (!entries) return { entries: [], note: "Console capture starts with pages loaded after the browser tab opened." }; + var recent = entries.slice(-${json(input.limit ?? 100)}); + var total = entries.length; + if (${json(!!input.clear)}) entries.length = 0; + return { entries: recent, total: total };`; +} + +export function clickScript(input: { + selector?: string; + text?: string; +}): string { + return `${DESCRIBE} + var selector = ${json(input.selector)}; + var text = ${json(input.text)}; + var el = null; + if (selector) { + el = document.querySelector(selector); + } else if (text) { + var needle = text.toLowerCase(); + var candidates = document.querySelectorAll( + 'a, button, input[type="submit"], input[type="button"], [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="option"], label, summary' + ); + for (var i = 0; i < candidates.length; i++) { + var c = candidates[i]; + var label = (c.innerText || c.value || c.getAttribute("aria-label") || "").toLowerCase(); + if (label.indexOf(needle) !== -1) { el = c; break; } + } + } else { + throw new Error("Pass a selector or text"); + } + if (!el) throw new Error("No element matches " + (selector || JSON.stringify(text))); + el.scrollIntoView({ block: "center", inline: "center" }); + var rect = el.getBoundingClientRect(); + var at = { bubbles: true, cancelable: true, view: window, button: 0, + clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + var described = describe(el); + // Click after this result is stored: a navigation would otherwise discard it. + setTimeout(function () { + if (typeof PointerEvent === "function") el.dispatchEvent(new PointerEvent("pointerdown", at)); + el.dispatchEvent(new MouseEvent("mousedown", at)); + if (el.focus) el.focus(); + if (typeof PointerEvent === "function") el.dispatchEvent(new PointerEvent("pointerup", at)); + el.dispatchEvent(new MouseEvent("mouseup", at)); + el.click(); + }, 0); + return { clicked: described };`; +} + +export function typeScript(input: { + selector: string; + text: string; + clear?: boolean; + submit?: boolean; +}): string { + return `${DESCRIBE} + var selector = ${json(input.selector)}; + var text = ${json(input.text)}; + var clear = ${json(input.clear !== false)}; + var submit = ${json(!!input.submit)}; + var el = document.querySelector(selector); + if (!el) throw new Error("No element matches " + selector); + el.scrollIntoView({ block: "center" }); + if (el.focus) el.focus(); + if (el.isContentEditable) { + if (clear) el.textContent = ""; + document.execCommand("insertText", false, text); + } else if (el instanceof HTMLSelectElement) { + var option = Array.prototype.find.call(el.options, function (o) { + return o.value === text || o.text.trim() === text; + }); + if (!option) throw new Error("No option " + JSON.stringify(text) + " in " + selector); + el.value = option.value; + } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + // Use the prototype setter so frameworks that track value (React) notice. + var proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + var setter = Object.getOwnPropertyDescriptor(proto, "value").set; + setter.call(el, (clear ? "" : el.value) + text); + el.dispatchEvent(new InputEvent("input", { bubbles: true, data: text, inputType: "insertText" })); + } else { + throw new Error(describe(el) + " is not editable"); + } + el.dispatchEvent(new Event("change", { bubbles: true })); + var value = "value" in el ? el.value : el.textContent; + if (submit) { + setTimeout(function () { + var form = el.form || el.closest("form"); + if (form && form.requestSubmit) form.requestSubmit(); + else if (form) form.submit(); + else { + var key = { key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true }; + el.dispatchEvent(new KeyboardEvent("keydown", key)); + el.dispatchEvent(new KeyboardEvent("keyup", key)); + } + }, 0); + } + return { typed: describe(el), value: value, submitted: submit };`; +} + +export function waitScript(input: { + selector?: string; + text?: string; + timeoutMs: number; +}): string { + return ` + var selector = ${json(input.selector)}; + var text = ${json(input.text)}; + if (!selector && !text) throw new Error("Pass a selector or text"); + var deadline = Date.now() + ${json(input.timeoutMs)}; + function found() { + if (selector) return !!document.querySelector(selector); + return !!document.body && document.body.innerText.indexOf(text) !== -1; + } + while (!found()) { + if (Date.now() > deadline) { + throw new Error("Timed out waiting for " + (selector || JSON.stringify(text))); + } + await new Promise(function (resolve) { setTimeout(resolve, 100); }); + } + return { found: true, url: location.href, title: document.title };`; +} diff --git a/src/features/browser/model/browserStore.test.ts b/src/features/browser/model/browserStore.test.ts new file mode 100644 index 0000000000..969fc88bfc --- /dev/null +++ b/src/features/browser/model/browserStore.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; +import { + closeBrowserTab, + closeOtherBrowserTabs, + forgetBrowserSessions, + openBrowserTab, + addBrowserTab, + AGENT_TAB_TTL_MS, + forgetBrowserSessionsIn, + liveBrowserTabIds, + loadGeneration, + noteLoadStarted, + settleLoad, + MAX_LIVE_TABS, + newBrowserTab, + parseBrowserState, + patchBrowserTabIn, + pruneAgentTabsIn, + serializeBrowserState, + switchBrowserSessionIn, + toggleBrowserDock, + touchAgentTabIn, + type BrowserState, +} from "./browserStore"; + +const empty: BrowserState = { + docks: [], + sessionId: "chat-a", + lastSide: "right", + agentTabs: {}, + recent: {}, +}; + +describe("session browsers", () => { + it("toggle opens a blank tab on the right, then hides and shows", () => { + const opened = toggleBrowserDock(empty); + expect(opened.docks).toHaveLength(1); + expect(opened.docks[0]).toMatchObject({ + sessionId: "chat-a", + side: "right", + open: true, + }); + expect(opened.docks[0].pane.files[0].browser?.url).toBe("about:blank"); + + const hidden = toggleBrowserDock(opened); + expect(hidden.docks[0].open).toBe(false); + expect(toggleBrowserDock(hidden).docks[0].open).toBe(true); + }); + + it("does nothing without a focused session", () => { + const none = { ...empty, sessionId: "" }; + expect(toggleBrowserDock(none)).toBe(none); + }); + + it("gives each session its own tabs and panel state", () => { + const a = newBrowserTab("https://a.test/"); + const b = newBrowserTab("https://b.test/"); + let state = addBrowserTab(empty, "chat-a", a); + state = addBrowserTab(state, "chat-b", b, { open: false }); + expect(state.docks.map((dock) => [dock.sessionId, dock.open])).toEqual([ + ["chat-a", true], + ["chat-b", false], + ]); + // Switching sessions shows the other session's browser, closed. + const switched = { ...state, sessionId: "chat-b" }; + expect(toggleBrowserDock(switched).docks[1].open).toBe(true); + }); + + it("opens a page's new window right after its opener", () => { + const a = newBrowserTab("https://a.test/"); + const b = newBrowserTab("https://b.test/"); + const c = newBrowserTab("https://c.test/"); + let state = addBrowserTab(empty, "chat-a", a); + state = addBrowserTab(state, "chat-a", b); + state = addBrowserTab(state, "chat-a", c, { afterId: a.id }); + expect(state.docks[0].pane.files.map((file) => file.id)).toEqual([ + a.id, + c.id, + b.id, + ]); + expect(state.docks[0].pane.activeFileId).toBe(c.id); + }); + + it("adds background tabs without opening the panel", () => { + const a = newBrowserTab("https://a.test/"); + const hidden = toggleBrowserDock(addBrowserTab(empty, "chat-a", a)); + expect(hidden.docks[0].open).toBe(false); + const next = addBrowserTab(hidden, "chat-a", newBrowserTab("https://x/"), { + open: false, + }); + expect(next.docks[0].open).toBe(false); + }); + + it("patches page state and leaves unchanged tabs alone", () => { + const tab = newBrowserTab("https://a.test/"); + const state = addBrowserTab(empty, "chat-a", tab); + const loaded = patchBrowserTabIn(state, tab.id, { + url: "https://a.test/next", + title: "Next", + }); + expect(loaded.docks[0].pane.files[0]).toMatchObject({ + path: "https://a.test/next", + browser: { url: "https://a.test/next", title: "Next" }, + }); + expect(patchBrowserTabIn(loaded, tab.id, { title: "Next" })).toBe(loaded); + expect(patchBrowserTabIn(loaded, "missing", { title: "x" })).toBe(loaded); + }); + + it("forgets the browsers of deleted sessions", () => { + let state = addBrowserTab(empty, "chat-a", newBrowserTab("https://a/")); + state = addBrowserTab(state, "chat-b", newBrowserTab("https://b/")); + const next = forgetBrowserSessionsIn(state, ["chat-b", "unknown"]); + expect(next.docks.map((dock) => dock.sessionId)).toEqual(["chat-a"]); + expect(forgetBrowserSessionsIn(next, ["unknown"])).toBe(next); + }); +}); + +describe("live and suspended tabs", () => { + it("keeps a session's pages running after switching away", () => { + const a = newBrowserTab("https://a/"); + const b = newBrowserTab("https://b/"); + let state = addBrowserTab(empty, "chat-a", a); + state = addBrowserTab(state, "chat-b", b, { open: false }); + state = switchBrowserSessionIn(state, "chat-b", 1_000); + expect(liveBrowserTabIds(state)).toEqual(new Set([a.id, b.id])); + expect(switchBrowserSessionIn(state, "chat-b", 2_000)).toBe(state); + }); + + it("unloads the least recently viewed pages past the limit", () => { + let state = empty; + const tabs: string[] = []; + for (let i = 0; i < MAX_LIVE_TABS + 2; i++) { + const tab = newBrowserTab(`https://${i}/`); + tabs.push(tab.id); + state = addBrowserTab(state, `chat-${i}`, tab); + state = switchBrowserSessionIn(state, `chat-${i}`, i); + } + const live = liveBrowserTabIds(state); + expect(live.size).toBe(MAX_LIVE_TABS); + // The focused session (last) stays; the two oldest sessions unload. + expect(live.has(tabs[tabs.length - 1])).toBe(true); + expect(live.has(tabs[0])).toBe(false); + expect(live.has(tabs[1])).toBe(false); + expect(live.has(tabs[2])).toBe(true); + }); + + it("never unloads the focused session or agent tabs to meet the limit", () => { + const a = newBrowserTab("https://a/"); + const b = newBrowserTab("https://b/"); + const c = newBrowserTab("https://c/"); + let state = addBrowserTab(empty, "chat-a", a); + state = addBrowserTab(state, "chat-a", b); + state = addBrowserTab(state, "chat-b", c, { open: false }); + state = touchAgentTabIn(state, c.id, 5); + expect(liveBrowserTabIds(state, 1)).toEqual(new Set([a.id, b.id, c.id])); + }); + + it("keeps the focused session's tabs and agent tabs live", () => { + const a = newBrowserTab("https://a/"); + const b = newBrowserTab("https://b/"); + const c = newBrowserTab("https://c/"); + let state = addBrowserTab(empty, "chat-a", a); + state = addBrowserTab(state, "chat-b", b, { open: false }); + state = addBrowserTab(state, "chat-b", c, { open: false }); + expect([...liveBrowserTabIds(state)]).toEqual([a.id]); + state = touchAgentTabIn(state, c.id, 1_000); + expect(liveBrowserTabIds(state)).toEqual(new Set([a.id, c.id])); + }); + + it("suspends agent tabs after a quiet period or once closed", () => { + const a = newBrowserTab("https://a/"); + let state = addBrowserTab(empty, "chat-b", a, { open: false }); + state = touchAgentTabIn(state, a.id, 1_000); + expect(pruneAgentTabsIn(state, 2_000)).toBe(state); + expect(pruneAgentTabsIn(state, 1_000 + AGENT_TAB_TTL_MS).agentTabs).toEqual( + {}, + ); + const closed = forgetBrowserSessionsIn(state, ["chat-b"]); + expect(pruneAgentTabsIn(closed, 2_000).agentTabs).toEqual({}); + }); +}); + +describe("browser persistence", () => { + it("round-trips docks without live page state", () => { + const tab = newBrowserTab("https://a.test/"); + const state = patchBrowserTabIn( + addBrowserTab(empty, "chat-a", tab), + tab.id, + { title: "A", loading: true, error: "boom" }, + ); + const restored = parseBrowserState(serializeBrowserState(state)); + expect(restored.lastSide).toBe("right"); + expect(restored.docks[0].sessionId).toBe("chat-a"); + expect(restored.docks[0].pane.files[0].browser).toEqual({ + url: "https://a.test/", + title: "A", + }); + }); + + it("drops malformed docks and tabs", () => { + expect(parseBrowserState("not json").docks).toEqual([]); + const raw = JSON.stringify({ + docks: [ + { sessionId: "a", side: "middle", pane: { files: [] } }, + { projectPath: "/old", side: "left", pane: { files: [] } }, + { + sessionId: "b", + side: "left", + size: 400, + open: true, + pane: { + id: "p", + activeFileId: "gone", + files: [ + { id: "../bad", browser: { url: "https://x.test" } }, + { + id: "ok-1", + path: "https://y.test", + cwd: "", + browser: { url: "https://y.test" }, + }, + ], + }, + }, + ], + }); + const { docks } = parseBrowserState(raw); + expect(docks).toHaveLength(1); + expect(docks[0].pane.files.map((file) => file.id)).toEqual(["ok-1"]); + expect(docks[0].pane.activeFileId).toBe("ok-1"); + }); +}); + +describe("load generations", () => { + it("settles only while no newer load has started", () => { + const id = openBrowserTab("https://a.test", { sessionId: "gen-s" })!; + expect(settleLoad(id, loadGeneration(id))).toBe(true); + const seen = loadGeneration(id); + noteLoadStarted(id); + expect(loadGeneration(id)).toBe(seen + 1); + expect(settleLoad(id, seen)).toBe(false); + forgetBrowserSessions(["gen-s"]); + }); + + it("ignores starts for tabs that are not in the store", () => { + noteLoadStarted("ghost-tab"); + expect(loadGeneration("ghost-tab")).toBe(0); + }); + + it("forgets generations when tabs close, close others, or sessions go", () => { + const a = openBrowserTab("https://a.test", { sessionId: "gen-c" })!; + const b = openBrowserTab("https://b.test", { sessionId: "gen-c" })!; + const c = openBrowserTab("https://c.test", { sessionId: "gen-c" })!; + for (const id of [a, b, c]) noteLoadStarted(id); + closeBrowserTab(a); + expect(loadGeneration(a)).toBe(0); + closeOtherBrowserTabs(b); + expect(loadGeneration(b)).toBe(1); + expect(loadGeneration(c)).toBe(0); + forgetBrowserSessions(["gen-c"]); + expect(loadGeneration(b)).toBe(0); + }); +}); diff --git a/src/features/browser/model/browserStore.ts b/src/features/browser/model/browserStore.ts new file mode 100644 index 0000000000..201036b39f --- /dev/null +++ b/src/features/browser/model/browserStore.ts @@ -0,0 +1,595 @@ +import { useSyncExternalStore } from "react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + addTerminalToDock, + clampDockSize, + closeTerminalInDock, + isDockSide, + reorderDockTerminals, + selectDockTerminal, + withDockOpen, + withDockSide, + withDockSize, + type DockSide, + type DockState, +} from "../../projects/model/projectTerminal"; +import { newEditorPane } from "../../workspace/model/layout"; +import type { + BrowserTabSource, + FilePaneTab, +} from "../../workspace/model/layout"; +import { BLANK_URL } from "./browserUrl"; + +/** One session's browser: its tabs and where its panel sits. */ +export type BrowserDock = DockState & { sessionId: string }; + +export type BrowserState = { + docks: BrowserDock[]; + /** Session the window is showing; "" when none. Its dock is the visible one. */ + sessionId: string; + lastSide: DockSide; + /** + * Tabs an agent used recently, by last use. They stay live while out of + * view so the agent can keep working. Not persisted. + */ + agentTabs: Record; + /** + * When each tab was last on screen or used by an agent. Out-of-view pages + * stay live, most recent first, up to MAX_LIVE_TABS. Not persisted. + */ + recent: Record; +}; + +export const BROWSER_DEFAULT_SIDE: DockSide = "right"; +/** How long an agent's tab stays live out of view after its last use. */ +export const AGENT_TAB_TTL_MS = 3 * 60_000; +/** + * Pages kept running at once. Each is a browser process, so past this the + * least recently viewed tabs of other sessions unload and reload from their + * URL when shown again. The focused session's tabs and agent tabs always stay. + */ +export const MAX_LIVE_TABS = 12; +const DEFAULT_SIZE = { horizontal: 520, vertical: 320 }; +const STORAGE_PREFIX = "monocode.browser.v2:"; + +export function browserDefaultSize(side: DockSide): number { + return side === "left" || side === "right" + ? DEFAULT_SIZE.horizontal + : DEFAULT_SIZE.vertical; +} + +export function newBrowserTab(url: string): FilePaneTab { + return { + id: crypto.randomUUID(), + path: url, + cwd: "", + browser: { url }, + }; +} + +// ---- pure transitions -------------------------------------------------- + +function viewport() { + return typeof window === "undefined" + ? undefined + : { width: window.innerWidth, height: window.innerHeight }; +} + +export function findBrowserDock( + docks: BrowserDock[], + sessionId: string, +): BrowserDock | undefined { + return sessionId + ? docks.find((dock) => dock.sessionId === sessionId) + : undefined; +} + +export function dockOfTab( + state: BrowserState, + fileId: string, +): BrowserDock | undefined { + return state.docks.find((dock) => + dock.pane.files.some((file) => file.id === fileId), + ); +} + +function mapDock( + state: BrowserState, + sessionId: string, + update: (dock: BrowserDock) => BrowserDock | null, +): BrowserState { + let changed = false; + const docks: BrowserDock[] = []; + for (const dock of state.docks) { + if (dock.sessionId !== sessionId) { + docks.push(dock); + continue; + } + const next = update(dock); + if (next !== dock) changed = true; + if (next) docks.push(next); + } + return changed ? { ...state, docks } : state; +} + +/** Map the dock that holds `fileId`. */ +function mapDockOfTab( + state: BrowserState, + fileId: string, + update: (dock: BrowserDock) => BrowserDock | null, +): BrowserState { + const dock = dockOfTab(state, fileId); + return dock ? mapDock(state, dock.sessionId, update) : state; +} + +export function addBrowserTab( + state: BrowserState, + sessionId: string, + file: FilePaneTab, + options: { afterId?: string; open?: boolean } = {}, +): BrowserState { + const existing = findBrowserDock(state.docks, sessionId); + if (!existing) { + const side = state.lastSide; + const dock: BrowserDock = { + sessionId, + pane: newEditorPane(file), + side, + size: clampDockSize(side, browserDefaultSize(side), viewport()), + open: options.open ?? true, + }; + return { ...state, docks: [...state.docks, dock] }; + } + return mapDock(state, sessionId, (dock) => { + let next = addTerminalToDock(dock, file); + if (options.open === false) next = { ...next, open: dock.open }; + const at = options.afterId + ? dock.pane.files.findIndex((entry) => entry.id === options.afterId) + : -1; + if (at < 0) return next; + const files = [...dock.pane.files]; + files.splice(at + 1, 0, file); + return { ...next, pane: { ...next.pane, files } }; + }); +} + +/** The status bar toggle: show or hide the current session's browser. */ +export function toggleBrowserDock(state: BrowserState): BrowserState { + if (!state.sessionId) return state; + if (!findBrowserDock(state.docks, state.sessionId)) { + return addBrowserTab(state, state.sessionId, newBrowserTab(BLANK_URL)); + } + return mapDock(state, state.sessionId, (dock) => + withDockOpen(dock, !dock.open), + ); +} + +export function patchBrowserTabIn( + state: BrowserState, + fileId: string, + patch: Partial, +): BrowserState { + return mapDockOfTab(state, fileId, (dock) => { + let changed = false; + const files = dock.pane.files.map((file) => { + if (file.id !== fileId || !file.browser) return file; + const browser = { ...file.browser, ...patch }; + if ( + browser.url === file.browser.url && + browser.title === file.browser.title && + browser.loading === file.browser.loading && + browser.error === file.browser.error + ) { + return file; + } + changed = true; + return { ...file, path: browser.url, browser }; + }); + return changed ? { ...dock, pane: { ...dock.pane, files } } : dock; + }); +} + +/** Drop the browsers of sessions that no longer exist. */ +export function forgetBrowserSessionsIn( + state: BrowserState, + sessionIds: Iterable, +): BrowserState { + const gone = new Set(sessionIds); + if (!state.docks.some((dock) => gone.has(dock.sessionId))) return state; + return { + ...state, + docks: state.docks.filter((dock) => !gone.has(dock.sessionId)), + }; +} + +export function touchAgentTabIn( + state: BrowserState, + fileId: string, + now: number, +): BrowserState { + return { + ...state, + agentTabs: { ...state.agentTabs, [fileId]: now }, + recent: { ...state.recent, [fileId]: now }, + }; +} + +/** Focus another session; the one leaving keeps its pages, newest first. */ +export function switchBrowserSessionIn( + state: BrowserState, + sessionId: string, + now: number, +): BrowserState { + if (state.sessionId === sessionId) return state; + const leaving = findBrowserDock(state.docks, state.sessionId); + const recent = { ...state.recent }; + for (const file of leaving?.pane.files ?? []) recent[file.id] = now; + return { ...state, sessionId, recent }; +} + +export function pruneAgentTabsIn( + state: BrowserState, + now: number, + ttl = AGENT_TAB_TTL_MS, +): BrowserState { + const open = new Set(browserTabIds(state)); + const entries = Object.entries(state.agentTabs); + const kept = entries.filter(([id, used]) => open.has(id) && now - used < ttl); + if (kept.length === entries.length) return state; + return { ...state, agentTabs: Object.fromEntries(kept) }; +} + +/** + * Tabs whose native page should exist: every tab of the session on screen + * and tabs an agent is using, then other sessions' tabs by how recently they + * were seen, up to `limit`. The rest are suspended to their URL. + */ +export function liveBrowserTabIds( + state: BrowserState, + limit = MAX_LIVE_TABS, +): Set { + const open = new Set(browserTabIds(state)); + const live = new Set( + Object.keys(state.agentTabs).filter((id) => open.has(id)), + ); + const current = findBrowserDock(state.docks, state.sessionId); + for (const file of current?.pane.files ?? []) live.add(file.id); + const others = Object.entries(state.recent) + .filter(([id]) => open.has(id) && !live.has(id)) + .sort((a, b) => b[1] - a[1]); + for (const [id] of others) { + if (live.size >= limit) break; + live.add(id); + } + return live; +} + +/** Every tab this window knows about. */ +export function browserTabIds(state: BrowserState): string[] { + return state.docks.flatMap((dock) => dock.pane.files.map((file) => file.id)); +} + +// ---- persistence ------------------------------------------------------- + +type StoredState = { + docks: BrowserDock[]; + lastSide?: DockSide; +}; + +export function serializeBrowserState(state: BrowserState): string { + const stored: StoredState = { + lastSide: state.lastSide, + docks: state.docks.map((dock) => ({ + ...dock, + pane: { + ...dock.pane, + files: dock.pane.files.map((file) => ({ + id: file.id, + path: file.path, + cwd: file.cwd, + browser: file.browser + ? { url: file.browser.url, title: file.browser.title } + : undefined, + })), + }, + })), + }; + return JSON.stringify(stored); +} + +export function parseBrowserState(raw: string | null): StoredState { + if (!raw) return { docks: [] }; + try { + const value = JSON.parse(raw) as Partial; + const docks = (Array.isArray(value.docks) ? value.docks : []).flatMap( + (dock): BrowserDock[] => { + if (!dock || typeof dock.sessionId !== "string" || !dock.sessionId) { + return []; + } + if (!isDockSide(dock.side) || !dock.pane) return []; + const files = (dock.pane.files ?? []).filter( + (file) => + typeof file?.id === "string" && + /^[\w-]{1,64}$/.test(file.id) && + typeof file.browser?.url === "string", + ); + if (files.length === 0) return []; + const activeFileId = files.some( + (file) => file.id === dock.pane.activeFileId, + ) + ? dock.pane.activeFileId + : files[0].id; + return [ + { + sessionId: dock.sessionId, + side: dock.side, + size: clampDockSize(dock.side, Number(dock.size)), + open: !!dock.open, + pane: { + id: dock.pane.id || crypto.randomUUID(), + files, + activeFileId, + }, + }, + ]; + }, + ); + return { + docks, + lastSide: isDockSide(value.lastSide) ? value.lastSide : undefined, + }; + } catch { + return { docks: [] }; + } +} + +// ---- store ------------------------------------------------------------- + +// Per window, like terminals: each window keeps its own sessions' browsers. +function storageKey(): string | null { + try { + return `${STORAGE_PREFIX}${getCurrentWindow().label}`; + } catch { + return null; + } +} + +function initialState(): BrowserState { + const key = typeof window === "undefined" ? null : storageKey(); + const stored = parseBrowserState(key ? localStorage.getItem(key) : null); + return { + docks: stored.docks, + sessionId: "", + lastSide: stored.lastSide ?? BROWSER_DEFAULT_SIDE, + agentTabs: {}, + recent: {}, + }; +} + +let state: BrowserState | null = null; +const listeners = new Set<() => void>(); +let saveTimer: ReturnType | undefined; +let pruneTimer: ReturnType | undefined; + +export function getBrowserState(): BrowserState { + state ??= initialState(); + return state; +} + +function setState(next: BrowserState) { + const previous = getBrowserState(); + if (next === previous) return; + state = next; + if (next.docks !== previous.docks) pruneLoadGenerations(next); + for (const listener of listeners) listener(); + if (next.docks === previous.docks && next.lastSide === previous.lastSide) { + return; + } + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + const key = storageKey(); + if (key && state) localStorage.setItem(key, serializeBrowserState(state)); + }, 250); +} + +function update(fn: (current: BrowserState) => BrowserState) { + setState(fn(getBrowserState())); +} + +export function subscribeBrowser(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useBrowserState(): BrowserState { + return useSyncExternalStore(subscribeBrowser, getBrowserState); +} + +export function setBrowserSession(sessionId: string) { + update((current) => switchBrowserSessionIn(current, sessionId, Date.now())); +} + +export function toggleBrowser() { + update(toggleBrowserDock); +} + +export function hideBrowser() { + update((current) => + mapDock(current, current.sessionId, (dock) => withDockOpen(dock, false)), + ); +} + +/** + * Open `url` in a new tab of a session's browser (the one on screen unless + * given). A user's tab shows the panel; an agent's (`background`) does not. + * Returns the tab id, or null when there is no session to hold it. + */ +export function openBrowserTab( + url: string = BLANK_URL, + options: { sessionId?: string; afterId?: string; background?: boolean } = {}, +): string | null { + const sessionId = options.sessionId ?? getBrowserState().sessionId; + if (!sessionId) return null; + const file = newBrowserTab(url); + const open = options.background ? false : true; + update((current) => { + const next = addBrowserTab(current, sessionId, file, { + afterId: options.afterId, + open, + }); + return options.background + ? next + : mapDock(next, sessionId, (dock) => withDockOpen(dock, true)); + }); + return file.id; +} + +export function selectBrowserTab(fileId: string) { + update((current) => + mapDockOfTab(current, fileId, (dock) => selectDockTerminal(dock, fileId)), + ); +} + +export function closeBrowserTab(fileId: string) { + update((current) => + mapDockOfTab(current, fileId, (dock) => closeTerminalInDock(dock, fileId)), + ); +} + +export function closeOtherBrowserTabs(fileId: string) { + update((current) => + mapDockOfTab(current, fileId, (dock) => { + const keep = dock.pane.files.filter((file) => file.id === fileId); + return { + ...dock, + pane: { ...dock.pane, files: keep, activeFileId: fileId }, + }; + }), + ); +} + +export function reorderBrowserTabs(ids: string[]) { + update((current) => + mapDock(current, current.sessionId, (dock) => { + const byId = new Map(dock.pane.files.map((file) => [file.id, file])); + const files = ids.flatMap((id) => byId.get(id) ?? []); + if (files.length !== dock.pane.files.length) return dock; + return reorderDockTerminals(dock, files); + }), + ); +} + +export function setBrowserSide(side: DockSide) { + update((current) => ({ + ...mapDock(current, current.sessionId, (dock) => + withDockSide(dock, side, viewport()), + ), + lastSide: side, + })); +} + +export function setBrowserSize(size: number) { + update((current) => + mapDock(current, current.sessionId, (dock) => + withDockSize(dock, size, viewport()), + ), + ); +} + +/** Counts native load starts per tab, so a late reply can tell it is stale. */ +const loadGenerations = new Map(); + +/** Drop counters of tabs that left the store; suspended tabs stay in it. */ +function pruneLoadGenerations(current: BrowserState) { + if ( + loadGenerations.size === 0 && + nativeUrlEpochs.size === 0 && + urlRevisions.size === 0 + ) { + return; + } + const live = new Set( + current.docks.flatMap((dock) => dock.pane.files.map((file) => file.id)), + ); + for (const id of [...loadGenerations.keys()]) { + if (!live.has(id)) loadGenerations.delete(id); + } + for (const id of [...nativeUrlEpochs.keys()]) { + if (!live.has(id)) nativeUrlEpochs.delete(id); + } + for (const id of [...urlRevisions.keys()]) { + if (!live.has(id)) urlRevisions.delete(id); + } +} + +export function loadGeneration(fileId: string): number { + return loadGenerations.get(fileId) ?? 0; +} + +/** Counts every native URL report (load start or finish) per tab. */ +const nativeUrlEpochs = new Map(); + +export function nativeUrlEpoch(fileId: string): number { + return nativeUrlEpochs.get(fileId) ?? 0; +} + +/** Counts every write to a tab's URL, so a slow native read can tell it is stale. */ +const urlRevisions = new Map(); + +export function urlRevision(fileId: string): number { + return urlRevisions.get(fileId) ?? 0; +} + +/** Record a native URL report: it is authoritative over any earlier guess. */ +export function noteNativeUrl(fileId: string) { + if (!dockOfTab(getBrowserState(), fileId)) return; + nativeUrlEpochs.set(fileId, nativeUrlEpoch(fileId) + 1); +} + +/** Record a native load finish: its URL (e.g. a redirect) is authoritative. */ +export function noteLoadFinished(fileId: string) { + noteNativeUrl(fileId); +} + +/** Record a native load start; includes same-URL reloads. */ +export function noteLoadStarted(fileId: string) { + if (!dockOfTab(getBrowserState(), fileId)) return; + loadGenerations.set(fileId, loadGeneration(fileId) + 1); + noteLoadFinished(fileId); +} + +/** + * Clear a tab's loading flag only if no newer load has started since + * `generation` was read. Returns whether it was cleared. + */ +export function settleLoad(fileId: string, generation: number): boolean { + if (loadGeneration(fileId) !== generation) return false; + patchBrowserTab(fileId, { loading: false }); + return true; +} + +export function patchBrowserTab( + fileId: string, + patch: Partial, +) { + if (patch.url !== undefined) { + urlRevisions.set(fileId, urlRevision(fileId) + 1); + } + update((current) => patchBrowserTabIn(current, fileId, patch)); +} + +export function forgetBrowserSessions(sessionIds: Iterable) { + update((current) => forgetBrowserSessionsIn(current, sessionIds)); +} + +/** Keep an agent's tab live out of view for a while. */ +export function touchAgentTab(fileId: string) { + update((current) => touchAgentTabIn(current, fileId, Date.now())); + clearTimeout(pruneTimer); + pruneTimer = setTimeout(function prune() { + update((current) => pruneAgentTabsIn(current, Date.now())); + if (Object.keys(getBrowserState().agentTabs).length > 0) { + pruneTimer = setTimeout(prune, 30_000); + } + }, 30_000); +} diff --git a/src/features/browser/model/browserUrl.test.ts b/src/features/browser/model/browserUrl.test.ts new file mode 100644 index 0000000000..ac832e4188 --- /dev/null +++ b/src/features/browser/model/browserUrl.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + browserUrlFromInput, + browserUrlLabel, + isBrowsableUrl, +} from "./browserUrl"; + +describe("browserUrlFromInput", () => { + it("keeps full web URLs", () => { + expect(browserUrlFromInput("https://example.com/a?b=1")).toBe( + "https://example.com/a?b=1", + ); + expect(browserUrlFromInput("http://example.com")).toBe( + "http://example.com/", + ); + }); + + it("adds https to bare hosts and http to local dev servers", () => { + expect(browserUrlFromInput("example.com")).toBe("https://example.com/"); + expect(browserUrlFromInput("docs.rs/tauri")).toBe("https://docs.rs/tauri"); + expect(browserUrlFromInput("localhost:3000")).toBe( + "http://localhost:3000/", + ); + expect(browserUrlFromInput("127.0.0.1:5173/app")).toBe( + "http://127.0.0.1:5173/app", + ); + expect(browserUrlFromInput("devbox:8080")).toBe("https://devbox:8080/"); + }); + + it("searches for everything else", () => { + expect(browserUrlFromInput("tauri child webview")).toBe( + "https://duckduckgo.com/?q=tauri%20child%20webview", + ); + expect(browserUrlFromInput("javascript:alert(1)")).toContain( + "duckduckgo.com", + ); + expect(browserUrlFromInput(" ")).toBe("about:blank"); + }); +}); + +describe("isBrowsableUrl", () => { + it("allows only web pages", () => { + expect(isBrowsableUrl("https://example.com")).toBe(true); + expect(isBrowsableUrl("about:blank")).toBe(true); + expect(isBrowsableUrl("file:///etc/hosts")).toBe(false); + expect(isBrowsableUrl("mailto:a@b.c")).toBe(false); + }); +}); + +describe("browserUrlLabel", () => { + it("shows the host", () => { + expect(browserUrlLabel("https://example.com/a")).toBe("example.com"); + expect(browserUrlLabel("about:blank")).toBe("New Tab"); + }); +}); diff --git a/src/features/browser/model/browserUrl.ts b/src/features/browser/model/browserUrl.ts new file mode 100644 index 0000000000..2a25ceae0b --- /dev/null +++ b/src/features/browser/model/browserUrl.ts @@ -0,0 +1,61 @@ +const LOCAL_HOST = + /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?(\/|$)/i; +const HAS_SCHEME = /^[a-z][a-z\d+.-]*:/i; +const LOOKS_LIKE_HOST = /^[^\s/]+\.[^\s/]+(\/|$)|^[^\s/]+:\d+(\/|$)/; + +export const BLANK_URL = "about:blank"; + +/** Whether the embedded browser may load this URL. */ +export function isBrowsableUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "http:" || + url.protocol === "https:" || + url.href === BLANK_URL + ); + } catch { + return false; + } +} + +/** + * What the address bar should load for typed text: a URL as-is, a bare host + * over https (http for local dev servers), anything else as a web search. + */ +export function browserUrlFromInput(input: string): string { + const text = input.trim(); + if (!text) return BLANK_URL; + if (HAS_SCHEME.test(text) && !isHostWithPort(text)) { + return isBrowsableUrl(text) ? new URL(text).href : searchUrl(text); + } + if (LOCAL_HOST.test(text)) return new URL(`http://${text}`).href; + if (!/\s/.test(text) && LOOKS_LIKE_HOST.test(text)) { + try { + return new URL(`https://${text}`).href; + } catch { + return searchUrl(text); + } + } + return searchUrl(text); +} + +// `localhost:3000` parses as a URL with scheme `localhost:`. +function isHostWithPort(text: string): boolean { + return /^[^\s/:]+:\d+(\/|$)/.test(text); +} + +function searchUrl(text: string): string { + return `https://duckduckgo.com/?q=${encodeURIComponent(text)}`; +} + +/** Short tab label for a page with no title yet. */ +export function browserUrlLabel(value: string): string { + if (!value || value === BLANK_URL) return "New Tab"; + try { + const url = new URL(value); + return url.host || value; + } catch { + return value; + } +} diff --git a/src/features/browser/model/nativeTabs.ts b/src/features/browser/model/nativeTabs.ts new file mode 100644 index 0000000000..52d190772a --- /dev/null +++ b/src/features/browser/model/nativeTabs.ts @@ -0,0 +1,50 @@ +/** Tabs whose native page exists in this window. Agents wait on these. */ +const ready = new Set(); +const waiters = new Map void>>(); + +export function setNativeTabReady(id: string, isReady: boolean) { + if (!isReady) { + ready.delete(id); + return; + } + ready.add(id); + for (const wake of waiters.get(id) ?? []) wake(); + waiters.delete(id); +} + +export function isNativeTabReady(id: string): boolean { + return ready.has(id); +} + +export function whenNativeTabReady( + id: string, + timeoutMs = 5000, + signal?: AbortSignal, +): Promise { + if (ready.has(id)) return Promise.resolve(); + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const list = waiters.get(id) ?? new Set<() => void>(); + waiters.set(id, list); + const settle = () => { + clearTimeout(timer); + list.delete(wake); + if (list.size === 0 && waiters.get(id) === list) waiters.delete(id); + signal?.removeEventListener("abort", abort); + }; + const wake = () => { + settle(); + resolve(); + }; + const abort = () => { + settle(); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + settle(); + reject(new Error("The browser tab did not open")); + }, timeoutMs); + list.add(wake); + signal?.addEventListener("abort", abort); + }); +} diff --git a/src/features/browser/model/nativeUrl.test.ts b/src/features/browser/model/nativeUrl.test.ts new file mode 100644 index 0000000000..5ae665cddc --- /dev/null +++ b/src/features/browser/model/nativeUrl.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + closeBrowserTab, + dockOfTab, + getBrowserState, + noteLoadStarted, + nativeUrlEpoch, + openBrowserTab, + parseBrowserState, + patchBrowserTab, + serializeBrowserState, + setBrowserSession, +} from "./browserStore"; +import { setNativeTabReady } from "./nativeTabs"; +import { syncNativeUrl, trackedBrowserTabIds } from "./nativeUrl"; + +const tab = (id: string) => + dockOfTab(getBrowserState(), id)?.pane.files.find((f) => f.id === id) + ?.browser; + +const opened: string[] = []; +function openLive(url = "https://example.com/app") { + const id = openBrowserTab(url, { sessionId: "s1", background: true })!; + opened.push(id); + setNativeTabReady(id, true); + patchBrowserTab(id, { loading: false }); + return id; +} + +afterEach(() => { + for (const id of opened.splice(0)) { + setNativeTabReady(id, false); + if (tab(id)) closeBrowserTab(id); + } +}); + +const at = (url: string) => async () => url; + +describe("same-document URL tracking", () => { + it.each([ + ["pushState", "https://example.com/app/items/7"], + ["replaceState", "https://example.com/app?tab=2"], + ["hash change", "https://example.com/app#section"], + ])("applies a %s URL without touching loading", async (_, url) => { + const id = openLive(); + patchBrowserTab(id, { title: "App" }); + const epoch = nativeUrlEpoch(id); + expect(await syncNativeUrl(id, at(url))).toBe(true); + expect(tab(id)).toMatchObject({ url, title: "App", loading: false }); + expect(nativeUrlEpoch(id)).toBe(epoch + 1); + }); + + it("changes nothing when the URL is the same", async () => { + const id = openLive(); + const epoch = nativeUrlEpoch(id); + expect(await syncNativeUrl(id, at(tab(id)!.url))).toBe(false); + expect(nativeUrlEpoch(id)).toBe(epoch); + }); + + it("drops a reply when a load started while it was in flight", async () => { + const id = openLive(); + const result = await syncNativeUrl(id, async () => { + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + return "https://example.com/stale"; + }); + expect(result).toBe(false); + expect(tab(id)?.url).not.toBe("https://example.com/stale"); + }); + + it("drops a reply when the URL was written while it was in flight", async () => { + const id = openLive(); + const result = await syncNativeUrl(id, async () => { + patchBrowserTab(id, { url: "https://example.com/typed" }); + return "https://example.com/stale"; + }); + expect(result).toBe(false); + expect(tab(id)?.url).toBe("https://example.com/typed"); + }); + + it("drops a reply for a tab that was closed or suspended", async () => { + const closing = openLive(); + expect( + await syncNativeUrl(closing, async () => { + closeBrowserTab(closing); + return "https://example.com/x"; + }), + ).toBe(false); + + const suspending = openLive(); + expect( + await syncNativeUrl(suspending, async () => { + setNativeTabReady(suspending, false); + return "https://example.com/y"; + }), + ).toBe(false); + expect(tab(suspending)?.url).toBe("https://example.com/app"); + }); + + it("ignores read failures and non-web URLs", async () => { + const id = openLive(); + expect( + await syncNativeUrl(id, async () => { + throw new Error("Browser tab is not open"); + }), + ).toBe(false); + expect(await syncNativeUrl(id, at("file:///etc/passwd"))).toBe(false); + }); + + it("tracks only live tabs with a native page", () => { + setBrowserSession("s1"); + const live = openLive(); + const suspended = openBrowserTab("https://example.com/s", { + sessionId: "s1", + background: true, + })!; + opened.push(suspended); + const ids = trackedBrowserTabIds(); + expect(ids).toContain(live); + expect(ids).not.toContain(suspended); + setNativeTabReady(live, false); + expect(trackedBrowserTabIds()).not.toContain(live); + }); + + it("restores a suspended tab at its pushState URL", async () => { + const id = openLive(); + await syncNativeUrl(id, at("https://example.com/app/items/7")); + setNativeTabReady(id, false); // suspended: the view is gone + const restored = parseBrowserState( + JSON.parse(JSON.stringify(serializeBrowserState(getBrowserState()))), + ); + const file = restored.docks + .flatMap((dock) => dock.pane.files) + .find((f) => f.id === id); + expect(file?.browser?.url).toBe("https://example.com/app/items/7"); + }); +}); diff --git a/src/features/browser/model/nativeUrl.ts b/src/features/browser/model/nativeUrl.ts new file mode 100644 index 0000000000..3593cfc957 --- /dev/null +++ b/src/features/browser/model/nativeUrl.ts @@ -0,0 +1,67 @@ +import { isNativeTabReady } from "./nativeTabs"; +import { + dockOfTab, + getBrowserState, + liveBrowserTabIds, + loadGeneration, + nativeUrlEpoch, + noteNativeUrl, + patchBrowserTab, + urlRevision, +} from "./browserStore"; +import { isBrowsableUrl } from "./browserUrl"; +import { isNavigating } from "./browserNavigation"; + +function tabBrowser(id: string) { + return dockOfTab(getBrowserState(), id)?.pane.files.find( + (file) => file.id === id, + )?.browser; +} + +/** Tabs whose native page is running now. Closed and suspended tabs have none. */ +export function trackedBrowserTabIds(): string[] { + const state = getBrowserState(); + return [...liveBrowserTabIds(state)].filter(isNativeTabReady); +} + +/** + * Pull one tab's URL from its native page and apply it when it moved without a + * load (pushState, replaceState, hash change). Only the URL changes; loading + * belongs to load events. A reply is dropped if the tab went away or was + * suspended, a load started, or anything wrote the URL while it was in flight. + */ +export async function syncNativeUrl( + id: string, + readUrl: (id: string) => Promise, +): Promise { + const before = tabBrowser(id); + if (!before || before.loading || isNavigating(id) || !isNativeTabReady(id)) { + return false; + } + const generation = loadGeneration(id); + const epoch = nativeUrlEpoch(id); + const revision = urlRevision(id); + let url: string; + try { + url = await readUrl(id); + } catch { + return false; + } + const tab = tabBrowser(id); + if ( + !tab || + tab.loading || + isNavigating(id) || + !isNativeTabReady(id) || + loadGeneration(id) !== generation || + nativeUrlEpoch(id) !== epoch || + urlRevision(id) !== revision || + url === tab.url || + !isBrowsableUrl(url) + ) { + return false; + } + noteNativeUrl(id); + patchBrowserTab(id, { url }); + return true; +} diff --git a/src/features/browser/model/openLink.test.ts b/src/features/browser/model/openLink.test.ts new file mode 100644 index 0000000000..06dad08aaa --- /dev/null +++ b/src/features/browser/model/openLink.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { linkTarget } from "./openLink"; + +const plain = { metaKey: false, ctrlKey: false }; +const cmd = { metaKey: true, ctrlKey: false }; +const ctrl = { metaKey: false, ctrlKey: true }; + +describe("linkTarget", () => { + it("follows the setting for a plain click", () => { + expect(linkTarget("https://example.com", true, plain)).toBe("browser"); + expect(linkTarget("https://example.com", false, plain)).toBe("external"); + expect(linkTarget("https://example.com", true)).toBe("browser"); + }); + + it("flips with the platform modifier", () => { + expect(linkTarget("https://example.com", true, cmd, true)).toBe("external"); + expect(linkTarget("https://example.com", false, cmd, true)).toBe("browser"); + expect(linkTarget("https://example.com", true, ctrl, true)).toBe("browser"); + expect(linkTarget("https://example.com", true, ctrl, false)).toBe( + "external", + ); + }); + + it("sends anything but web pages to the system", () => { + expect(linkTarget("mailto:a@b.c", true, plain)).toBe("external"); + expect(linkTarget("file:///etc/hosts", true, cmd, true)).toBe("external"); + }); +}); diff --git a/src/features/browser/model/openLink.ts b/src/features/browser/model/openLink.ts new file mode 100644 index 0000000000..79659d0ab9 --- /dev/null +++ b/src/features/browser/model/openLink.ts @@ -0,0 +1,40 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { IS_MAC } from "../../../platform/tauri/platform"; +import { loadOpenLinksInApp } from "../../settings/model/displayPrefs"; +import { isBrowsableUrl } from "./browserUrl"; +import { openBrowserTab } from "./browserStore"; + +type Modifiers = Pick; + +export type LinkTarget = "browser" | "external"; + +/** + * Where a clicked link goes. The setting picks the usual target; holding the + * platform modifier (⌘ on macOS, Ctrl elsewhere) picks the other one. Only + * web pages can open in the built-in browser. + */ +export function linkTarget( + url: string, + preferBrowser: boolean, + modifiers?: Modifiers | null, + mac = IS_MAC, +): LinkTarget { + if (!/^https?:/i.test(url) || !isBrowsableUrl(url)) return "external"; + const flipped = !!modifiers && (mac ? modifiers.metaKey : modifiers.ctrlKey); + return preferBrowser !== flipped ? "browser" : "external"; +} + +/** Open a link the user clicked in app content (chat, terminal). */ +export function openLink( + url: string, + modifiers?: Modifiers | null, +): Promise { + // Tabs belong to the focused session; with none, use the system browser. + if ( + linkTarget(url, loadOpenLinksInApp(), modifiers) === "browser" && + openBrowserTab(url) !== null + ) { + return Promise.resolve(); + } + return openUrl(url); +} diff --git a/src/features/browser/ui/BrowserStatusButton.tsx b/src/features/browser/ui/BrowserStatusButton.tsx new file mode 100644 index 0000000000..fd272bdb7e --- /dev/null +++ b/src/features/browser/ui/BrowserStatusButton.tsx @@ -0,0 +1,34 @@ +import { Globe } from "../../../shared/ui/icons"; +import { findBrowserDock, toggleBrowser, useBrowserState } from "../model/browserStore"; + +/** Status bar toggle for the focused session's browser. */ +export function BrowserStatusButton() { + const state = useBrowserState(); + if (!state.sessionId) return null; + const dock = findBrowserDock(state.docks, state.sessionId); + const open = !!dock?.open; + // An agent is working in this session's browser while the panel is hidden. + const agentBusy = + !open && + !!dock?.pane.files.some((file) => file.id in state.agentTabs); + const label = open ? "Hide Browser" : "Show Browser"; + return ( + + ); +} diff --git a/src/features/browser/ui/BrowserView.tsx b/src/features/browser/ui/BrowserView.tsx new file mode 100644 index 0000000000..2fb493010c --- /dev/null +++ b/src/features/browser/ui/BrowserView.tsx @@ -0,0 +1,300 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type FormEvent, +} from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + ChevronLeft, + ChevronRight, + ExternalLink, + RefreshCw, + X, +} from "../../../shared/ui/icons"; +import { IconButton } from "../../../app/shell/TitleBar"; +import { + browserHistory, + closeBrowserView, + openBrowserView, + setBrowserBounds, + setBrowserVisible, + type BrowserBounds, +} from "../../../platform/tauri/browser"; +import { loadUiScale, subscribeUiScale } from "../../settings/model/uiScale"; +import type { BrowserTabSource } from "../../workspace/model/layout"; +import { BLANK_URL, browserUrlFromInput } from "../model/browserUrl"; +import { + loadGeneration, + patchBrowserTab, + settleLoad, +} from "../model/browserStore"; +import { useOverlayOcclusion } from "../hooks/useOverlayOcclusion"; +import { setNativeTabReady } from "../model/nativeTabs"; +import { navigateBrowserTab } from "../model/browserNavigation"; + +/** + * React StrictMode, dock moves and tab switches unmount and remount a surface + * in one turn. Defer the native close so a remount can take the live page back. + */ +const pendingClose = new Map>(); + +function sameBounds(a: BrowserBounds | null, b: BrowserBounds): boolean { + return ( + !!a && + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height + ); +} + +/** + * The native page for one tab, drawn over this placeholder. While mounted + * the page is live; unmounting suspends it to its URL. + */ +export function BrowserSurface({ + id, + tab, + visible, + offWindow = false, + resizing = false, + className = "", +}: { + id: string; + tab: BrowserTabSource; + /** Draw the page. Off-window hosts pass true so the page keeps rendering. */ + visible: boolean; + /** The host is placed outside the window, where no overlay can cover it. */ + offWindow?: boolean; + /** The dock sash is being dragged. */ + resizing?: boolean; + /** Layout classes, replacing the default `relative min-h-0 flex-1`. */ + className?: string; +}) { + const host = useRef(null); + const scale = useSyncExternalStore(subscribeUiScale, loadUiScale); + const covered = useOverlayOcclusion(host, visible && !offWindow); + const shown = visible && !covered; + const [ready, setReady] = useState(false); + const placed = useRef(null); + + const measure = useCallback((): BrowserBounds | null => { + const el = host.current; + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.left * scale), + y: Math.round(rect.top * scale), + width: Math.max(1, Math.round(rect.width * scale)), + height: Math.max(1, Math.round(rect.height * scale)), + }; + }, [scale]); + + // Create the native page once; a quick remount reuses it. + useLayoutEffect(() => { + const queued = pendingClose.get(id); + if (queued) { + clearTimeout(queued); + pendingClose.delete(id); + } + const bounds = measure() ?? { x: 0, y: 0, width: 1, height: 1 }; + placed.current = bounds; + let alive = true; + patchBrowserTab(id, { error: undefined }); + openBrowserView(id, tab.url, bounds, false) + .then(() => { + if (!alive) return; + setReady(true); + setNativeTabReady(id, true); + }) + .catch( + (reason: unknown) => + alive && patchBrowserTab(id, { error: String(reason) }), + ); + return () => { + alive = false; + setReady(false); + setNativeTabReady(id, false); + pendingClose.set( + id, + setTimeout(() => { + pendingClose.delete(id); + void closeBrowserView(id).catch(() => undefined); + }, 0), + ); + }; + // Navigation after mount goes through navigateBrowser, not a remount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + useEffect(() => { + if (!ready) return; + void setBrowserVisible(id, shown).catch(() => undefined); + }, [id, ready, shown]); + + // Follow the placeholder. Size changes come from the observer; position-only + // moves (a sidebar opening beside a left dock) from the window and layout. + useEffect(() => { + if (!ready || !visible) return; + const el = host.current; + if (!el) return; + let frame: number | null = null; + const sync = () => { + frame = null; + const bounds = measure(); + if (!bounds || sameBounds(placed.current, bounds)) return; + placed.current = bounds; + void setBrowserBounds(id, bounds).catch(() => undefined); + }; + const schedule = () => { + if (frame == null) frame = requestAnimationFrame(sync); + }; + const observer = new ResizeObserver(schedule); + observer.observe(el); + const main = el.closest("main"); + if (main) observer.observe(main); + window.addEventListener("resize", schedule); + schedule(); + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + if (frame != null) cancelAnimationFrame(frame); + }; + }, [id, measure, ready, visible]); + + // While the sash is dragged the dock repaints every frame. + useEffect(() => { + if (!resizing || !ready) return; + let frame = requestAnimationFrame(function follow() { + const bounds = measure(); + if (bounds && !sameBounds(placed.current, bounds)) { + placed.current = bounds; + void setBrowserBounds(id, bounds).catch(() => undefined); + } + frame = requestAnimationFrame(follow); + }); + return () => cancelAnimationFrame(frame); + }, [id, measure, ready, resizing]); + + return ( +
+ {tab.error ? ( +
+ {tab.error} +
+ ) : null} +
+ ); +} + +/** Back, forward, reload and the address bar for one tab. */ +export function BrowserToolbar({ + id, + tab, +}: { + id: string; + tab: BrowserTabSource; +}) { + const [address, setAddress] = useState(tab.url === BLANK_URL ? "" : tab.url); + const [editing, setEditing] = useState(false); + const input = useRef(null); + + useEffect(() => { + if (!editing) setAddress(tab.url === BLANK_URL ? "" : tab.url); + }, [editing, tab.url]); + + useEffect(() => { + if (tab.url === BLANK_URL) input.current?.focus(); + // Only when a blank tab first appears. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const go = (event: FormEvent) => { + event.preventDefault(); + const url = browserUrlFromInput(address); + setEditing(false); + input.current?.blur(); + void navigateBrowserTab(id, url).catch(() => undefined); + }; + + return ( +
+ void browserHistory(id, "back").catch(() => undefined)} + > + + + + void browserHistory(id, "forward").catch(() => undefined) + } + > + + + { + // Stopping may emit no Finished event, so settle loading here. + const stopping = !!tab.loading; + const generation = loadGeneration(id); + void browserHistory(id, stopping ? "stop" : "reload") + .then(() => { + if (stopping) settleLoad(id, generation); + }) + .catch(() => undefined); + }} + > + {tab.loading ? ( + + ) : ( + + )} + + { + setEditing(true); + event.currentTarget.select(); + }} + onBlur={() => setEditing(false)} + onChange={(event) => setAddress(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + setEditing(false); + setAddress(tab.url === BLANK_URL ? "" : tab.url); + event.currentTarget.blur(); + }} + /> + { + if (tab.url !== BLANK_URL) + void openUrl(tab.url).catch(() => undefined); + }} + > + + +
+ ); +} diff --git a/src/features/browser/ui/SessionBrowserDock.test.ts b/src/features/browser/ui/SessionBrowserDock.test.ts new file mode 100644 index 0000000000..8b52dd56e7 --- /dev/null +++ b/src/features/browser/ui/SessionBrowserDock.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../platform/tauri/browser", () => ({ + openBrowserView: vi.fn(async () => undefined), + closeBrowserView: vi.fn(async () => undefined), + setBrowserBounds: vi.fn(async () => undefined), + setBrowserVisible: vi.fn(async () => undefined), + navigateBrowser: vi.fn(async () => undefined), + browserHistory: vi.fn(async () => undefined), + retainBrowserViews: vi.fn(async () => undefined), + onBrowserEvent: vi.fn(async () => () => undefined), +})); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => { + throw new Error("no window"); + }, +})); + +import { browserHistory } from "../../../platform/tauri/browser"; +import { BrowserDockLayout } from "./SessionBrowserDock"; +import { + closeBrowserTab, + forgetBrowserSessions, + dockOfTab, + getBrowserState, + hideBrowser, + noteLoadStarted, + openBrowserTab, + patchBrowserTab, + selectBrowserTab, + toggleBrowser, +} from "../model/browserStore"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + forgetBrowserSessions(["chat-a"]); + globalThis.ResizeObserver ??= class { + observe() {} + disconnect() {} + unobserve() {} + } as unknown as typeof ResizeObserver; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + forgetBrowserSessions(["chat-a"]); + vi.unstubAllGlobals(); +}); + +describe("BrowserDockLayout", () => { + it("shows one toolbar however many tabs links open", async () => { + await act(async () => + root.render( + createElement(BrowserDockLayout, { + sessionId: "chat-a", + hidden: false, + children: null, + }), + ), + ); + const ids: string[] = []; + const expectAddress = (url: string) => { + const bars = container.querySelectorAll('input[aria-label="Address"]'); + expect(bars).toHaveLength(1); + expect((bars[0] as HTMLInputElement).value).toBe(url); + }; + for (const url of [ + "https://a.test/", + "https://b.test/", + "https://c.test/", + ]) { + await act(async () => { + ids.push(openBrowserTab(url)!); + }); + expectAddress(url); + } + await act(async () => selectBrowserTab(ids[0])); + expectAddress("https://a.test/"); + await act(async () => + patchBrowserTab(ids[0], { url: "https://a.test/next" }), + ); + expectAddress("https://a.test/next"); + await act(async () => closeBrowserTab(ids[0])); + expectAddress("https://b.test/"); + await act(async () => hideBrowser()); + expect( + container.querySelectorAll('input[aria-label="Address"]'), + ).toHaveLength(0); + await act(async () => toggleBrowser()); + expectAddress("https://b.test/"); + }); + + it("keeps background pages outside a hidden workspace", async () => { + // Settings, search and the inbox hide the workspace with display: none, + // where a page would measure 1x1. + container.style.display = "none"; + await act(async () => + root.render( + createElement(BrowserDockLayout, { + sessionId: "chat-a", + hidden: true, + children: null, + }), + ), + ); + await act(async () => { + openBrowserTab("https://a.test/"); + }); + const host = document.body.querySelector( + "[inert][aria-hidden]", + ); + expect(host).not.toBeNull(); + expect(host!.childElementCount).toBe(1); + expect(container.contains(host)).toBe(false); + }); + + describe("Stop", () => { + const loadingOf = (id: string) => + dockOfTab(getBrowserState(), id)?.pane.files.find((f) => f.id === id) + ?.browser?.loading; + + async function startStop() { + await act(async () => + root.render( + createElement(BrowserDockLayout, { + sessionId: "chat-a", + hidden: false, + children: null, + }), + ), + ); + let id!: string; + await act(async () => { + id = openBrowserTab("https://a.test/")!; + }); + await act(async () => patchBrowserTab(id, { loading: true })); + const button = container.querySelector( + 'button[aria-label="Stop"]', + ) as HTMLButtonElement; + expect(button).toBeTruthy(); + return { id, button }; + } + + it("clears loading once the stop is acknowledged", async () => { + const { id, button } = await startStop(); + await act(async () => button.click()); + expect(browserHistory).toHaveBeenLastCalledWith(id, "stop"); + expect(loadingOf(id)).toBe(false); + }); + + it("keeps loading when the stop is rejected", async () => { + vi.mocked(browserHistory).mockRejectedValueOnce(new Error("no view")); + const { id, button } = await startStop(); + await act(async () => button.click()); + expect(loadingOf(id)).toBe(true); + }); + + it("keeps a newer load that starts before the stop resolves", async () => { + let resolve!: () => void; + vi.mocked(browserHistory).mockImplementationOnce( + () => new Promise((r) => (resolve = r)), + ); + const { id, button } = await startStop(); + await act(async () => button.click()); + await act(async () => { + noteLoadStarted(id); + patchBrowserTab(id, { loading: true }); + }); + await act(async () => resolve()); + expect(loadingOf(id)).toBe(true); + }); + }); +}); diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx new file mode 100644 index 0000000000..ae0a2b739f --- /dev/null +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -0,0 +1,256 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { DockPanel } from "../../workspace/ui/DockPanel"; +import { applyDockGridStyle } from "../../projects/model/projectTerminal"; +import { + onBrowserEvent, + readBrowserUrl, + retainBrowserViews, +} from "../../../platform/tauri/browser"; +import { + browserDefaultSize, + browserTabIds, + closeBrowserTab, + closeOtherBrowserTabs, + dockOfTab, + findBrowserDock, + getBrowserState, + hideBrowser, + liveBrowserTabIds, + openBrowserTab, + patchBrowserTab, + reorderBrowserTabs, + selectBrowserTab, + setBrowserSession, + setBrowserSide, + setBrowserSize, + useBrowserState, + type BrowserDock, + noteLoadFinished, + noteLoadStarted, +} from "../model/browserStore"; +import { syncNativeUrl, trackedBrowserTabIds } from "../model/nativeUrl"; +import { BrowserSurface, BrowserToolbar } from "./BrowserView"; + +/** How often live pages are asked for a URL that changed without a load. */ +const URL_POLL_MS = 1_000; + +/** Size of the off-window page an agent works in while the panel is hidden. */ +const BACKGROUND_SIZE = { width: 1280, height: 800 }; + +function SessionBrowserDock({ + dock, + onSizePaint, + onSizeCommit, +}: { + dock: BrowserDock; + onSizePaint: (size: number) => void; + onSizeCommit: (size: number) => void; +}) { + const [resizing, setResizing] = useState(false); + const active = dock.pane.files.find( + (file) => file.id === dock.pane.activeFileId, + ); + return ( + openBrowserTab()} + onSelect={selectBrowserTab} + onClose={closeBrowserTab} + onCloseOthers={closeOtherBrowserTabs} + onReorder={reorderBrowserTabs} + > + {active?.browser ? ( + // One key on the wrapper: switching tabs swaps toolbar and page together. +
+ + +
+ ) : null} +
+ ); +} + +/** Keep this window's tabs in step with their native pages. */ +function useBrowserEvents() { + useEffect(() => { + void retainBrowserViews(browserTabIds(getBrowserState())).catch( + () => undefined, + ); + const unlisten = onBrowserEvent((event) => { + if (event.kind === "load") { + if (event.loading) noteLoadStarted(event.id); + else noteLoadFinished(event.id); + patchBrowserTab(event.id, { + url: event.url, + loading: event.loading, + ...(event.loading ? { error: undefined } : {}), + }); + } else if (event.kind === "title") { + patchBrowserTab(event.id, { title: event.title }); + } else { + const dock = dockOfTab(getBrowserState(), event.id); + if (!dock) return; + openBrowserTab(event.url, { + sessionId: dock.sessionId, + afterId: event.id, + background: !dock.open, + }); + } + }); + // pushState, replaceState and hash changes emit no native load event. + let polling = false; + const poll = setInterval(() => { + if (polling) return; + polling = true; + void Promise.all( + trackedBrowserTabIds().map((id) => syncNativeUrl(id, readBrowserUrl)), + ).finally(() => { + polling = false; + }); + }, URL_POLL_MS); + return () => { + clearInterval(poll); + void unlisten.then((fn) => fn()); + }; + }, []); +} + +/** + * Lays the focused session's browser dock beside `children`, the way the + * terminal dock sits beside the workspace. Only the docked tab draws on + * screen. Other live tabs (the session's other tabs, recently viewed + * sessions' tabs, and tabs an agent is using) keep running in an off-window + * host, so switching sessions never reloads them. Past the live-page limit + * the least recently viewed are suspended to their URL. + */ +export function BrowserDockLayout({ + sessionId, + hidden, + children, +}: { + /** The focused session, or "" when none. */ + sessionId: string; + /** Something covers the workspace (settings, inbox, search). */ + hidden: boolean; + children: ReactNode; +}) { + const state = useBrowserState(); + const grid = useRef(null); + const dragging = useRef(false); + useBrowserEvents(); + + useLayoutEffect(() => { + setBrowserSession(sessionId); + }, [sessionId]); + + const current = findBrowserDock(state.docks, state.sessionId); + const show = !!current?.open && !hidden; + const showSide = show ? current.side : null; + const size = current?.size ?? 0; + const dockedId = show ? current.pane.activeFileId : null; + const live = liveBrowserTabIds(state); + const background = state.docks.flatMap((dock) => + dock.pane.files.filter( + (file) => file.browser && live.has(file.id) && file.id !== dockedId, + ), + ); + + useLayoutEffect(() => { + if (dragging.current || !grid.current) return; + applyDockGridStyle(grid.current, showSide, size); + }, [showSide, size]); + + const paint = useCallback( + (next: number) => { + if (!grid.current || !showSide) return; + dragging.current = true; + applyDockGridStyle(grid.current, showSide, next); + }, + [showSide], + ); + const commit = useCallback((next: number) => { + dragging.current = false; + setBrowserSize(next); + }, []); + + return ( +
+ {show ? ( +
+ +
+ ) : null} +
+ {children} +
+ {/* Outside the workspace: settings, search and the inbox hide it with + display: none, which would measure these pages at 1x1. */} + {background.length > 0 + ? createPortal( +
+ {background.map((file) => + file.browser ? ( + + ) : null, + )} +
, + document.body, + ) + : null} +
+ ); +} diff --git a/src/features/projects/model/projectTerminal.ts b/src/features/projects/model/projectTerminal.ts index c6d5bc0528..b9a02ee9f8 100644 --- a/src/features/projects/model/projectTerminal.ts +++ b/src/features/projects/model/projectTerminal.ts @@ -19,14 +19,18 @@ import { workspaceTabCwd } from "../../workspace/model/workspaceTabGroups"; export type DockSide = "top" | "bottom" | "left" | "right"; -export type ProjectTerminalDock = { - projectPath: string; +/** What every dock tracks: one pane of tabs, where it sits, and whether it shows. */ +export type DockState = { pane: EditorPane; side: DockSide; size: number; open: boolean; }; +export type ProjectTerminalDock = DockState & { + projectPath: string; +}; + export const DOCK_SIZE_DEFAULT = { top: 220, bottom: 220, @@ -91,10 +95,10 @@ export function createProjectTerminal( }; } -export function addTerminalToDock( - dock: ProjectTerminalDock, +export function addTerminalToDock( + dock: T, file: FilePaneTab, -): ProjectTerminalDock { +): T { return { ...dock, open: true, @@ -113,10 +117,10 @@ export function nextDockTerminalTitle( return nextTerminalTitleFromFiles(dock.pane.files, cwd); } -export function closeTerminalInDock( - dock: ProjectTerminalDock, +export function closeTerminalInDock( + dock: T, fileId: string, -): ProjectTerminalDock | null { +): T | null { const index = dock.pane.files.findIndex((file) => file.id === fileId); if (index < 0) return dock; const files = dock.pane.files.filter((file) => file.id !== fileId); @@ -138,10 +142,10 @@ export function focusedDockTerminalId( return files.some((file) => file.id === activeFileId) ? activeFileId : null; } -export function selectDockTerminal( - dock: ProjectTerminalDock, +export function selectDockTerminal( + dock: T, fileId: string, -): ProjectTerminalDock { +): T { if ( !dock.pane.files.some((file) => file.id === fileId) || dock.pane.activeFileId === fileId @@ -151,10 +155,10 @@ export function selectDockTerminal( return { ...dock, pane: { ...dock.pane, activeFileId: fileId } }; } -export function reorderDockTerminals( - dock: ProjectTerminalDock, +export function reorderDockTerminals( + dock: T, files: FilePaneTab[], -): ProjectTerminalDock { +): T { return { ...dock, pane: { ...dock.pane, files } }; } @@ -207,18 +211,18 @@ export function mapProjectTerminal( return found ? next : docks; } -export function withDockOpen( - dock: ProjectTerminalDock, +export function withDockOpen( + dock: T, open: boolean, -): ProjectTerminalDock { +): T { return dock.open === open ? dock : { ...dock, open }; } -export function withDockSide( - dock: ProjectTerminalDock, +export function withDockSide( + dock: T, side: DockSide, viewport?: { width: number; height: number }, -): ProjectTerminalDock { +): T { if (dock.side === side) return dock; return { ...dock, @@ -227,11 +231,11 @@ export function withDockSide( }; } -export function withDockSize( - dock: ProjectTerminalDock, +export function withDockSize( + dock: T, size: number, viewport?: { width: number; height: number }, -): ProjectTerminalDock { +): T { const next = clampDockSize(dock.side, size, viewport); return next === dock.size ? dock : { ...dock, size: next }; } diff --git a/src/features/sessions/data/sessionStore.ts b/src/features/sessions/data/sessionStore.ts index 5e8c769c3a..2310399eed 100644 --- a/src/features/sessions/data/sessionStore.ts +++ b/src/features/sessions/data/sessionStore.ts @@ -49,6 +49,7 @@ import type { import { HARNESSES, RUNTIME_MODES } from "../model/session"; import { restoreOrchestrationProposal } from "../../orchestration/model/orchestrationPlan"; +import { forgetBrowserSessions } from "../../browser/model/browserStore"; import type { OrchestrationSummary } from "../../orchestration/model/orchestrationSummary"; @@ -758,6 +759,9 @@ export async function deleteSession( invoke("session_delete", { sessionId, imagePaths }), ); monoSavedBlocks.delete(sessionId); + // Its browser tabs go with it. Only deletion does this: a session that + // leaves memory while idle is still saved and keeps its tabs. + forgetBrowserSessions([sessionId]); const tombstone = setTimeout( () => deletedSessionIds.delete(sessionId), 60_000, diff --git a/src/features/sessions/data/sessionStoreBrowser.test.ts b/src/features/sessions/data/sessionStoreBrowser.test.ts new file mode 100644 index 0000000000..9dab30a40b --- /dev/null +++ b/src/features/sessions/data/sessionStoreBrowser.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); + +async function load() { + vi.resetModules(); + const browser = await import("../../browser/model/browserStore"); + const store = await import("./sessionStore"); + return { browser, store }; +} + +const docked = (browser: { + getBrowserState: () => { docks: { sessionId: string }[] }; +}) => + browser + .getBrowserState() + .docks.map((dock) => dock.sessionId) + .sort(); + +afterEach(() => { + mocks.invoke.mockReset(); +}); + +describe("deleting a session", () => { + it("forgets that session's browser tabs, and only after the delete succeeds", async () => { + const { browser, store } = await load(); + browser.openBrowserTab("https://a.test/", { sessionId: "gone" }); + browser.openBrowserTab("https://b.test/", { sessionId: "kept" }); + expect(docked(browser)).toEqual(["gone", "kept"]); + + mocks.invoke.mockRejectedValueOnce(new Error("disk full")); + await expect(store.deleteSession("gone")).rejects.toThrow("disk full"); + expect(docked(browser)).toEqual(["gone", "kept"]); + + mocks.invoke.mockResolvedValue(undefined); + await store.deleteSession("gone"); + expect(docked(browser)).toEqual(["kept"]); + }); +}); diff --git a/src/features/sessions/ui/AgentMarkdown.tsx b/src/features/sessions/ui/AgentMarkdown.tsx index 52fe425504..18834bd123 100644 --- a/src/features/sessions/ui/AgentMarkdown.tsx +++ b/src/features/sessions/ui/AgentMarkdown.tsx @@ -1,5 +1,5 @@ +import { openLink } from "../../browser/model/openLink"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { createContext, isValidElement, @@ -274,7 +274,7 @@ function MarkdownLink({ } event.preventDefault(); if (href && /^https?:\/\//i.test(href)) { - void openUrl(href).catch((error) => { + void openLink(href, event).catch((error) => { console.error("Failed to open web link:", error); }); } diff --git a/src/features/sessions/ui/AgentMarkdownMenu.test.ts b/src/features/sessions/ui/AgentMarkdownMenu.test.ts index 64ebb67a7c..65e7fc05c9 100644 --- a/src/features/sessions/ui/AgentMarkdownMenu.test.ts +++ b/src/features/sessions/ui/AgentMarkdownMenu.test.ts @@ -8,6 +8,7 @@ const actions = vi.hoisted(() => ({ copyText: vi.fn(async () => {}), openPathWithDefaultApp: vi.fn(async () => {}), openUrl: vi.fn(async () => {}), + openLink: vi.fn(async () => {}), revealPath: vi.fn(async () => {}), })); @@ -15,6 +16,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: actions.openUrl, })); +vi.mock("../../browser/model/openLink", () => ({ + openLink: actions.openLink, +})); + vi.mock("../../../platform/tauri/clipboard", () => ({ copyText: actions.copyText, })); @@ -119,7 +124,7 @@ describe("AgentMarkdown file link context menu", () => { expect(menu).toBeNull(); }); - it("opens external web links in the default browser", async () => { + it("routes web links through the in-app link opener", async () => { props = { text: "[Website](https://example.com/docs)", cwd: "/repo", @@ -135,7 +140,10 @@ describe("AgentMarkdown file link context menu", () => { await act(async () => link.dispatchEvent(event)); expect(event.defaultPrevented).toBe(true); - expect(actions.openUrl).toHaveBeenCalledWith("https://example.com/docs"); + expect(actions.openLink).toHaveBeenCalledWith( + "https://example.com/docs", + expect.objectContaining({ type: "click" }), + ); expect(props.onOpenFile).not.toHaveBeenCalled(); }); diff --git a/src/features/sessions/ui/UserLinkPreview.test.ts b/src/features/sessions/ui/UserLinkPreview.test.ts index 8e25fa0aa4..12da3b722a 100644 --- a/src/features/sessions/ui/UserLinkPreview.test.ts +++ b/src/features/sessions/ui/UserLinkPreview.test.ts @@ -7,6 +7,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(async () => undefined), })); +vi.mock("../../browser/model/openLink", () => ({ + openLink: vi.fn(async () => undefined), +})); + vi.mock("../../inbox/model/githubTasks", () => ({ formatRelativeTime: () => "2 hours ago", githubWorkItem: vi.fn(async () => ({ @@ -34,7 +38,7 @@ vi.mock("../../inbox/model/githubTasks", () => ({ peekGithubWorkItemDetails: () => null, })); -import { openUrl } from "@tauri-apps/plugin-opener"; +import { openLink } from "../../browser/model/openLink"; import { githubWorkItem, githubWorkItemDetails } from "../../inbox/model/githubTasks"; import type { UserLink } from "../model/linkPreview"; import { UserLinkPreview } from "./UserLinkPreview"; @@ -148,7 +152,10 @@ describe("GitHub work item link preview", () => { act(() => chip.click()); - expect(openUrl).toHaveBeenCalledWith(link.url); + expect(openLink).toHaveBeenCalledWith( + link.url, + expect.objectContaining({ type: "click" }), + ); }); it("uses a compact treatment centered with inline message text", () => { diff --git a/src/features/sessions/ui/UserLinkPreview.tsx b/src/features/sessions/ui/UserLinkPreview.tsx index 59698ef175..532345f426 100644 --- a/src/features/sessions/ui/UserLinkPreview.tsx +++ b/src/features/sessions/ui/UserLinkPreview.tsx @@ -1,4 +1,4 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; +import { openLink } from "../../browser/model/openLink"; import { useCallback, useEffect, @@ -87,7 +87,7 @@ function GenericLinkPreview({ link }: { link: UserLink }) { title={link.url} aria-label={`Open ${title}`} className="user-link-preview group mx-0.5 text-sky-400/90 hover:text-sky-300 hover:underline" - onClick={(event) => openExternalLink(event, link.url)} + onClick={(event) => openWebLink(event, link.url)} > {favicon ? ( @@ -255,7 +255,7 @@ function GithubWorkItemPreview({ onBlur={hideNow} onClick={(event) => { hideNow(); - openExternalLink(event, link.url); + openWebLink(event, link.url); }} > , url: string) { +function openWebLink(event: MouseEvent, url: string) { event.preventDefault(); event.stopPropagation(); - void openUrl(url).catch((error) => { + void openLink(url, event).catch((error) => { console.error("Failed to open web link:", error); }); } diff --git a/src/features/settings/model/displayPrefs.ts b/src/features/settings/model/displayPrefs.ts index 98f0502922..0ccc2edadb 100644 --- a/src/features/settings/model/displayPrefs.ts +++ b/src/features/settings/model/displayPrefs.ts @@ -85,6 +85,37 @@ export const saveMaskEmails = maskEmails.save; export const subscribeMaskEmails = maskEmails.subscribe; export const useMaskEmails = maskEmails.useFlag; +const OPEN_LINKS_IN_APP_KEY = "monocode.openLinksInApp"; +export const OPEN_LINKS_IN_APP_DEFAULT = true; +/** Fired on `window` whenever the link target flips (detail: boolean). */ +export const OPEN_LINKS_IN_APP_CHANGE_EVENT = "monocode:openlinksinappchange"; + +const openLinksInApp = flagStore( + OPEN_LINKS_IN_APP_KEY, + OPEN_LINKS_IN_APP_DEFAULT, + OPEN_LINKS_IN_APP_CHANGE_EVENT, +); + +/** Chat and terminal links open in the built-in browser, not the default one. */ +export const loadOpenLinksInApp = openLinksInApp.load; +export const saveOpenLinksInApp = openLinksInApp.save; +export const useOpenLinksInApp = openLinksInApp.useFlag; + +const AGENT_BROWSER_KEY = "monocode.agentBrowser"; +export const AGENT_BROWSER_DEFAULT = true; +/** Fired on `window` whenever agent browser access flips (detail: boolean). */ +export const AGENT_BROWSER_CHANGE_EVENT = "monocode:agentbrowserchange"; + +const agentBrowser = flagStore( + AGENT_BROWSER_KEY, + AGENT_BROWSER_DEFAULT, + AGENT_BROWSER_CHANGE_EVENT, +); + +/** Chats get browser tools (navigate, read, run scripts) for the built-in browser. */ +export const loadAgentBrowser = agentBrowser.load; +export const saveAgentBrowser = agentBrowser.save; +export const useAgentBrowser = agentBrowser.useFlag; /** Session and mono composers spell check and autocorrect what you type. */ export const loadComposerAutocorrect = composerAutocorrect.load; export const saveComposerAutocorrect = composerAutocorrect.save; diff --git a/src/features/settings/model/settings.ts b/src/features/settings/model/settings.ts index 3825252260..825698ebca 100644 --- a/src/features/settings/model/settings.ts +++ b/src/features/settings/model/settings.ts @@ -256,6 +256,18 @@ export const SETTINGS_INDEX: SettingsEntry[] = [ label: "File tabs", keywords: "editor open top workspace normal session pane beside chat", }, + { + id: "open-links", + section: "general", + label: "Open links in", + keywords: "browser built-in embedded external default url web chat terminal", + }, + { + id: "agent-browser", + section: "general", + label: "Agent browser", + keywords: "browser mcp tools automation agents chat control web page", + }, { id: "tab-animations", section: "general", diff --git a/src/features/settings/ui/SettingsView.tsx b/src/features/settings/ui/SettingsView.tsx index 1a35a3b3a3..be1efe356b 100644 --- a/src/features/settings/ui/SettingsView.tsx +++ b/src/features/settings/ui/SettingsView.tsx @@ -237,12 +237,16 @@ import { } from "../../providers/model/providerAccountIdentity"; import { ProviderAccountSubtitle } from "../../providers/ui/ProviderAccountSubtitle"; import { + saveAgentBrowser, saveComposerAutocorrect, saveMaskEmails, + saveOpenLinksInApp, saveRailMonosPinned, saveShowRemainingUsage, + useAgentBrowser, useComposerAutocorrect, useMaskEmails, + useOpenLinksInApp, useRailMonosPinned, useShowRemainingUsage, } from "../model/displayPrefs"; @@ -902,6 +906,8 @@ function GeneralPage({ onChange={onFileTabMode} /> + + + saveOpenLinksInApp(value === "app")} + /> + + ); +} + +function AgentBrowserSetting() { + const enabled = useAgentBrowser(); + return ( + + + + ); +} + function UsageDisplaySettings() { const showRemainingUsage = useShowRemainingUsage(); const maskEmails = useMaskEmails(); diff --git a/src/features/terminal/model/terminalLinks.test.ts b/src/features/terminal/model/terminalLinks.test.ts new file mode 100644 index 0000000000..fb42735189 --- /dev/null +++ b/src/features/terminal/model/terminalLinks.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { oscLinkNeedsConfirm } from "./terminalLinks"; + +describe("oscLinkNeedsConfirm", () => { + it("opens a link that shows its own target", () => { + expect(oscLinkNeedsConfirm("https://a.test/x", "https://a.test/x")).toBe( + false, + ); + expect(oscLinkNeedsConfirm(" https://a.test/x ", "https://a.test/x")).toBe( + false, + ); + }); + + it("asks when the visible text differs from the target", () => { + expect(oscLinkNeedsConfirm("docs", "https://a.test/x")).toBe(true); + expect(oscLinkNeedsConfirm("https://a.test/x", "https://evil.test/")).toBe( + true, + ); + // Only part of a wrapped link is on this row. + expect(oscLinkNeedsConfirm("https://a.test/", "https://a.test/x")).toBe( + true, + ); + }); +}); diff --git a/src/features/terminal/model/terminalLinks.ts b/src/features/terminal/model/terminalLinks.ts new file mode 100644 index 0000000000..680db5d023 --- /dev/null +++ b/src/features/terminal/model/terminalLinks.ts @@ -0,0 +1,18 @@ +import type { IBufferRange, Terminal } from "@xterm/xterm"; + +/** + * The text an OSC 8 hyperlink shows on screen. xterm reports one row per + * range, with 1-based, inclusive columns. + */ +export function oscLinkLabel(term: Terminal, range: IBufferRange): string { + const line = term.buffer.active.getLine(range.start.y - 1); + return line?.translateToString(true, range.start.x - 1, range.end.x) ?? ""; +} + +/** + * An OSC 8 hyperlink can show one address and open another. Ask before opening + * one unless it shows exactly where it goes. + */ +export function oscLinkNeedsConfirm(label: string, uri: string): boolean { + return label.trim() !== uri; +} diff --git a/src/features/terminal/ui/ProjectTerminalDock.tsx b/src/features/terminal/ui/ProjectTerminalDock.tsx index 22c78d5841..deba9acc14 100644 --- a/src/features/terminal/ui/ProjectTerminalDock.tsx +++ b/src/features/terminal/ui/ProjectTerminalDock.tsx @@ -1,27 +1,6 @@ +import { DockPanel } from "../../workspace/ui/DockPanel"; import { - ChevronDown, - ChevronLeft, - ChevronRight, - ChevronUp, - PanelBottom, - PanelLeft, - PanelRight, - PanelTop, - Plus, -} from "../../../shared/ui/icons"; -import { - useEffect, - useRef, - useState, - type PointerEvent as ReactPointerEvent, -} from "react"; -import { ExplorerMenu } from "../../files/ui/ExplorerMenu"; -import { SurfaceTabs } from "../../workspace/ui/SurfaceTabs"; -import { IconButton } from "../../../app/shell/TitleBar"; -import { - clampDockSize, defaultDockSize, - isVerticalDock, type DockSide, type ProjectTerminalDock, } from "../../projects/model/projectTerminal"; @@ -50,27 +29,6 @@ type Props = { onTerminalMetaChange?: (fileId: string, patch: TerminalMetaPatch) => void; }; -const SIDE_ITEMS: { id: DockSide; label: string }[] = [ - { id: "bottom", label: "Dock Bottom" }, - { id: "top", label: "Dock Top" }, - { id: "left", label: "Dock Left" }, - { id: "right", label: "Dock Right" }, -]; - -function sideIcon(side: DockSide) { - if (side === "top") return PanelTop; - if (side === "left") return PanelLeft; - if (side === "right") return PanelRight; - return PanelBottom; -} - -function hideIcon(side: DockSide) { - if (side === "top") return ChevronUp; - if (side === "left") return ChevronLeft; - if (side === "right") return ChevronRight; - return ChevronDown; -} - export function ProjectTerminalDock({ dock, focused, @@ -86,208 +44,44 @@ export function ProjectTerminalDock({ onReorderTerminals, onTerminalMetaChange, }: Props) { - const vertical = isVerticalDock(dock.side); - const [dragging, setDragging] = useState(false); - const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); - const sideButton = useRef(null); - const drag = useRef<{ start: number; size: number } | null>(null); - const sizeRef = useRef(dock.size); - sizeRef.current = dock.size; - const pending = useRef(dock.size); - const frame = useRef(null); - const SideIcon = sideIcon(dock.side); - const HideIcon = hideIcon(dock.side); - - useEffect(() => { - if (!dragging) return; - const previous = document.body.style.cursor; - document.body.style.cursor = vertical ? "row-resize" : "col-resize"; - return () => { - document.body.style.cursor = previous; - }; - }, [dragging, vertical]); - - useEffect( - () => () => { - if (frame.current != null) cancelAnimationFrame(frame.current); - }, - [], - ); - - const viewport = () => ({ - width: window.innerWidth, - height: window.innerHeight, - }); - - const paint = (next: number) => { - pending.current = next; - if (frame.current != null) return; - frame.current = requestAnimationFrame(() => { - frame.current = null; - onSizePaint(pending.current); - }); - }; - - const commit = () => { - if (frame.current != null) { - cancelAnimationFrame(frame.current); - frame.current = null; - } - onSizeCommit(pending.current); - }; - - const onResizePointerDown = (event: ReactPointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - drag.current = { - start: vertical ? event.clientY : event.clientX, - size: sizeRef.current, - }; - pending.current = sizeRef.current; - setDragging(true); - }; - - const onResizePointerMove = (event: ReactPointerEvent) => { - if (!drag.current) return; - const point = vertical ? event.clientY : event.clientX; - const delta = point - drag.current.start; - const signed = - dock.side === "bottom" || dock.side === "right" ? -delta : delta; - paint(clampDockSize(dock.side, drag.current.size + signed, viewport())); - }; - - const onResizePointerUp = (event: ReactPointerEvent) => { - if (!drag.current) return; - drag.current = null; - commit(); - setDragging(false); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }; - - const sash = - dock.side === "top" - ? "absolute inset-x-0 -bottom-px z-10 h-1.5 cursor-row-resize touch-none" - : dock.side === "bottom" - ? "absolute inset-x-0 -top-px z-10 h-1.5 cursor-row-resize touch-none" - : dock.side === "left" - ? "absolute inset-y-0 -right-px z-10 w-1.5 cursor-col-resize touch-none" - : "absolute inset-y-0 -left-px z-10 w-1.5 cursor-col-resize touch-none"; - return ( -
-
{ - pending.current = defaultDockSize(dock.side); - commit(); - }} - /> - - - - -
- { - const rect = sideButton.current?.getBoundingClientRect(); - if (!rect) return; - setMenu({ x: rect.left, y: rect.bottom + 4 }); - }} - > - - -
- - - -
- } - /> -
- {dock.pane.files.map((file) => ( -
- onTerminalMetaChange?.(file.id, patch)} - /> -
- ))} -
- {menu ? ( - ({ - kind: "item" as const, - id: item.id, - label: item.label, - checked: item.id === dock.side, - }))} - onPick={(id) => { - if (id === "top" || id === "bottom" || id === "left" || id === "right") { - onSideChange(id); - } - setMenu(null); - }} - onClose={() => setMenu(null)} - /> - ) : null} -
+ {dock.pane.files.map((file) => ( +
+ onTerminalMetaChange?.(file.id, patch)} + /> +
+ ))} + ); } - -const EMPTY_IDS = new Set(); -const EMPTY_ERRORS = new Map(); diff --git a/src/features/terminal/ui/TerminalView.test.ts b/src/features/terminal/ui/TerminalView.test.ts index aee230f833..91adc8ee65 100644 --- a/src/features/terminal/ui/TerminalView.test.ts +++ b/src/features/terminal/ui/TerminalView.test.ts @@ -5,11 +5,18 @@ import { afterEach, expect, it, vi } from "vitest"; import type { WebLinksAddon } from "@xterm/addon-web-links"; import type { ILink, ITerminalOptions } from "@xterm/xterm"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ask } from "@tauri-apps/plugin-dialog"; +import { openLink } from "../../browser/model/openLink"; vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(async () => {}), })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ ask: vi.fn(async () => true) })); +vi.mock("../../browser/model/openLink", () => ({ + openLink: vi.fn((uri: string) => openUrl(uri)), +})); + const pty = vi.hoisted(() => ({ spawnPty: vi.fn(async () => {}), killPty: vi.fn(async () => {}), @@ -21,6 +28,7 @@ const pty = vi.hoisted(() => ({ vi.mock("../../../platform/tauri/pty", () => pty); const xterm = vi.hoisted(() => ({ options: [] as ITerminalOptions[], + linkLabel: "", loadAddon: vi.fn(), })); vi.mock("../model/terminalLayout", () => ({ @@ -38,7 +46,7 @@ vi.mock("@xterm/xterm", () => ({ options = {}; parser = { registerOscHandler: () => ({ dispose() {} }) }; buffer = { - active: { type: "normal" }, + active: { type: "normal", getLine: () => ({ translateToString: () => xterm.linkLabel }) }, onBufferChange: () => ({ dispose() {} }), }; open() {} @@ -157,7 +165,8 @@ it("opens only HTTP(S) URLs dispatched by the OSC 8 link handler", async () => { "HTTPS://example.com", ]; for (const uri of urls) { - handler.activate(new MouseEvent("click"), uri, undefined); + xterm.linkLabel = uri; + handler.activate(new MouseEvent("click"), uri, { start: { x: 1, y: 1 }, end: { x: uri.length, y: 1 } }); } expect(vi.mocked(openUrl).mock.calls).toEqual(urls.map((url) => [url])); vi.mocked(openUrl).mockClear(); @@ -167,7 +176,8 @@ it("opens only HTTP(S) URLs dispatched by the OSC 8 link handler", async () => { "mailto:test@example.com", "ftp://example.com", ]) { - handler.activate(new MouseEvent("click"), uri, undefined); + xterm.linkLabel = uri; + handler.activate(new MouseEvent("click"), uri, { start: { x: 1, y: 1 }, end: { x: uri.length, y: 1 } }); } expect(openUrl).not.toHaveBeenCalled(); } finally { @@ -312,3 +322,27 @@ it("uses the terminal-specific font stack", async () => { document.documentElement.style.removeProperty("--font-terminal"); } }); + +it("asks before opening an OSC link whose label hides its destination", async () => { + const { host, root } = setup(); + try { + await act(async () => root.render(createElement(TerminalView, { + id: "hidden-link", cwd: "/tmp", active: true, + }))); + const handler = xterm.options[0].linkHandler!; + xterm.linkLabel = "A different destination"; + const uri = "https://example.com"; + const range = { start: { x: 1, y: 1 }, end: { x: 23, y: 1 } }; + vi.mocked(ask).mockResolvedValueOnce(false); + await act(async () => handler.activate(new MouseEvent("click"), uri, range)); + expect(ask).toHaveBeenCalled(); + expect(openLink).not.toHaveBeenCalled(); + vi.mocked(ask).mockResolvedValueOnce(true); + const event = new MouseEvent("click", { metaKey: true, ctrlKey: true }); + await act(async () => handler.activate(event, uri, range)); + expect(openLink).toHaveBeenCalledExactlyOnceWith(uri, event); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/features/terminal/ui/TerminalView.tsx b/src/features/terminal/ui/TerminalView.tsx index ac03da1c67..241b104454 100644 --- a/src/features/terminal/ui/TerminalView.tsx +++ b/src/features/terminal/ui/TerminalView.tsx @@ -1,6 +1,5 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal } from "@xterm/xterm"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { useEffect, useRef } from "react"; import { getPtyStatus, @@ -28,6 +27,9 @@ import { type TerminalFitMode, } from "../model/terminalLayout"; import { IS_MAC } from "../../../platform/tauri/platform"; +import { ask } from "@tauri-apps/plugin-dialog"; +import { openLink } from "../../browser/model/openLink"; +import { oscLinkLabel, oscLinkNeedsConfirm } from "../model/terminalLinks"; import "@xterm/xterm/css/xterm.css"; type Props = { @@ -148,12 +150,12 @@ function oscColors() { } /** - * Open an HTTP(S) terminal link in the default browser, ignoring other schemes - * and opener failures. + * Open an HTTP(S) terminal link using the app's browser preference and the + * click's modifiers, ignoring other schemes and opener failures. */ -function openTerminalLink(uri: string) { +function openTerminalLink(uri: string, event: MouseEvent) { if (!/^https?:\/\//i.test(uri)) return; - void openUrl(uri).catch(() => undefined); + return openLink(uri, event).catch(() => undefined); } export function TerminalView({ id, cwd, active, onMetaChange }: Props) { @@ -183,9 +185,29 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { smoothScrollDuration: 0, theme: terminalTheme(isLightScheme()), macOptionIsMeta: IS_MAC, - linkHandler: { activate: (_event, uri) => openTerminalLink(uri) }, + linkHandler: { + // Replaces xterm's own prompt, which uses window.confirm: the webview + // has no confirm panel, so that never opens anything. + activate: (event, uri, range) => { + if (!/^https?:\/\//i.test(uri)) return; + void (async () => { + if ( + oscLinkNeedsConfirm(oscLinkLabel(term, range), uri) && + !(await ask(`Open this terminal link?\n\n${uri}`, { + title: "MonoCode", + kind: "warning", + })) + ) { + return; + } + await openTerminalLink(uri, event); + })().catch((error) => { + console.error("Failed to open web link:", error); + }); + }, + }, }); - term.loadAddon(new WebLinksAddon((_event, uri) => openTerminalLink(uri))); + term.loadAddon(new WebLinksAddon((event, uri) => { void openTerminalLink(uri, event); })); term.open(host); termRef.current = term; let closed = false; diff --git a/src/features/workspace/model/layout.ts b/src/features/workspace/model/layout.ts index f396a4df94..63541f9620 100644 --- a/src/features/workspace/model/layout.ts +++ b/src/features/workspace/model/layout.ts @@ -75,12 +75,23 @@ export type FilePaneTab = { /** Read-only transcript of an orchestration worker. Live only — not persisted. */ agent?: AgentTabSource; terminal?: boolean; + /** Embedded browser page. Only browser docks hold these. */ + browser?: BrowserTabSource; /** Foreground command when it isn't the shell. Live only — not persisted. */ foreground?: string; /** Temporary tab: the next preview open in its pane replaces it. */ preview?: boolean; }; +export type BrowserTabSource = { + url: string; + title?: string; + /** Live only — not persisted. */ + loading?: boolean; + /** Why the page could not open or load. Live only. */ + error?: string; +}; + export type EditorPane = { id: string; files: FilePaneTab[]; @@ -424,6 +435,12 @@ export function isTerminalTab(file: FilePaneTab): boolean { return !!file.terminal; } +export function isBrowserTab( + file: FilePaneTab, +): file is FilePaneTab & { browser: BrowserTabSource } { + return !!file.browser; +} + export function isAgentTab( file: FilePaneTab, ): file is FilePaneTab & { agent: AgentTabSource } { @@ -441,7 +458,10 @@ export function isVirtualDocumentTab(file: FilePaneTab): boolean { export function isFilesystemTab(file: FilePaneTab): boolean { return ( - !isTerminalTab(file) && !isVirtualDocumentTab(file) && !file.sessionChanges + !isTerminalTab(file) && + !isBrowserTab(file) && + !isVirtualDocumentTab(file) && + !file.sessionChanges ); } diff --git a/src/features/workspace/ui/DockPanel.tsx b/src/features/workspace/ui/DockPanel.tsx new file mode 100644 index 0000000000..a0ee6c6ef2 --- /dev/null +++ b/src/features/workspace/ui/DockPanel.tsx @@ -0,0 +1,298 @@ +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + PanelBottom, + PanelLeft, + PanelRight, + PanelTop, + Plus, +} from "../../../shared/ui/icons"; +import { + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { ExplorerMenu } from "../../files/ui/ExplorerMenu"; +import { SurfaceTabs } from "./SurfaceTabs"; +import { IconButton } from "../../../app/shell/TitleBar"; +import { + clampDockSize, + isVerticalDock, + type DockSide, + type DockState, +} from "../../projects/model/projectTerminal"; + +type Props = { + dock: DockState; + /** Singular surface name: "Terminal", "Browser". */ + name: string; + /** Accessible name of the tab strip. */ + tabsLabel: string; + addLabel: string; + hideLabel: string; + /** Size a double-click on the sash restores. */ + defaultSize: (side: DockSide) => number; + /** + * Keep the whole sash outside the dock. Native content (a browser page) + * covers the DOM, so a sash that overlaps it could not be grabbed there. + */ + sashOutside?: boolean; + /** A `data-` attribute set on the dock's root, so callers can find it in the DOM. */ + marker?: `data-${string}`; + onFocus?: () => void; + onHide: () => void; + onSideChange: (side: DockSide) => void; + onSizePaint: (size: number) => void; + onSizeCommit: (size: number) => void; + /** Called with the sash state so native content can stay out of the way. */ + onResizingChange?: (resizing: boolean) => void; + onAdd: () => void; + onSelect: (fileId: string) => void; + onClose: (fileId: string) => void; + onCloseOthers: (fileId: string) => void; + onReorder: (ids: string[]) => void; + children: ReactNode; +}; + +const SIDE_ITEMS: { id: DockSide; label: string }[] = [ + { id: "bottom", label: "Dock Bottom" }, + { id: "top", label: "Dock Top" }, + { id: "left", label: "Dock Left" }, + { id: "right", label: "Dock Right" }, +]; + +function sideIcon(side: DockSide) { + if (side === "top") return PanelTop; + if (side === "left") return PanelLeft; + if (side === "right") return PanelRight; + return PanelBottom; +} + +function hideIcon(side: DockSide) { + if (side === "top") return ChevronUp; + if (side === "left") return ChevronLeft; + if (side === "right") return ChevronRight; + return ChevronDown; +} + +function sashClass(side: DockSide, outside: boolean): string { + const base = "absolute z-10 touch-none"; + if (side === "top") { + return `${base} inset-x-0 h-1.5 cursor-row-resize ${outside ? "-bottom-1.5" : "-bottom-px"}`; + } + if (side === "bottom") { + return `${base} inset-x-0 h-1.5 cursor-row-resize ${outside ? "-top-1.5" : "-top-px"}`; + } + if (side === "left") { + return `${base} inset-y-0 w-1.5 cursor-col-resize ${outside ? "-right-1.5" : "-right-px"}`; + } + return `${base} inset-y-0 w-1.5 cursor-col-resize ${outside ? "-left-1.5" : "-left-px"}`; +} + +/** Resizable, movable dock with a tab strip: the frame shared by every dock. */ +export function DockPanel({ + dock, + name, + tabsLabel, + addLabel, + hideLabel, + defaultSize, + sashOutside = false, + marker, + onFocus, + onHide, + onSideChange, + onSizePaint, + onSizeCommit, + onResizingChange, + onAdd, + onSelect, + onClose, + onCloseOthers, + onReorder, + children, +}: Props) { + const vertical = isVerticalDock(dock.side); + const [dragging, setDragging] = useState(false); + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const sideButton = useRef(null); + const drag = useRef<{ start: number; size: number } | null>(null); + const sizeRef = useRef(dock.size); + sizeRef.current = dock.size; + const pending = useRef(dock.size); + const frame = useRef(null); + const SideIcon = sideIcon(dock.side); + const HideIcon = hideIcon(dock.side); + + useEffect(() => { + if (!dragging) return; + const previous = document.body.style.cursor; + document.body.style.cursor = vertical ? "row-resize" : "col-resize"; + return () => { + document.body.style.cursor = previous; + }; + }, [dragging, vertical]); + + useEffect(() => { + onResizingChange?.(dragging); + }, [dragging, onResizingChange]); + + useEffect( + () => () => { + if (frame.current != null) cancelAnimationFrame(frame.current); + }, + [], + ); + + const viewport = () => ({ + width: window.innerWidth, + height: window.innerHeight, + }); + + const paint = (next: number) => { + pending.current = next; + if (frame.current != null) return; + frame.current = requestAnimationFrame(() => { + frame.current = null; + onSizePaint(pending.current); + }); + }; + + const commit = () => { + if (frame.current != null) { + cancelAnimationFrame(frame.current); + frame.current = null; + } + onSizeCommit(pending.current); + }; + + const onResizePointerDown = (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { + start: vertical ? event.clientY : event.clientX, + size: sizeRef.current, + }; + pending.current = sizeRef.current; + setDragging(true); + }; + + const onResizePointerMove = (event: ReactPointerEvent) => { + if (!drag.current) return; + const point = vertical ? event.clientY : event.clientX; + const delta = point - drag.current.start; + const signed = + dock.side === "bottom" || dock.side === "right" ? -delta : delta; + paint(clampDockSize(dock.side, drag.current.size + signed, viewport())); + }; + + const onResizePointerUp = (event: ReactPointerEvent) => { + if (!drag.current) return; + drag.current = null; + commit(); + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + return ( +
+
{ + pending.current = defaultSize(dock.side); + commit(); + }} + /> + + + + +
+ { + const rect = sideButton.current?.getBoundingClientRect(); + if (!rect) return; + setMenu({ x: rect.left, y: rect.bottom + 4 }); + }} + > + + +
+ + + +
+ } + /> +
{children}
+ {menu ? ( + ({ + kind: "item" as const, + id: item.id, + label: item.label, + checked: item.id === dock.side, + }))} + onPick={(id) => { + if ( + id === "top" || + id === "bottom" || + id === "left" || + id === "right" + ) { + onSideChange(id); + } + setMenu(null); + }} + onClose={() => setMenu(null)} + /> + ) : null} +
+ ); +} + +const EMPTY_IDS = new Set(); +const EMPTY_ERRORS = new Map(); diff --git a/src/features/workspace/ui/SurfaceTabs.tsx b/src/features/workspace/ui/SurfaceTabs.tsx index 728fa51dee..9da1faeb59 100644 --- a/src/features/workspace/ui/SurfaceTabs.tsx +++ b/src/features/workspace/ui/SurfaceTabs.tsx @@ -1,5 +1,6 @@ import { GitCompare, + Globe, GripVertical, Terminal, X, @@ -14,6 +15,7 @@ import { } from "../../../platform/tauri/fs"; import { isAgentTab, + isBrowserTab, isChangesTab, isCommitTab, isFilesystemTab, @@ -28,6 +30,7 @@ import { displayPath } from "../../../shared/lib/paths"; import { IS_MAC, IS_WIN } from "../../../platform/tauri/platform"; import { releaseNotesTitle } from "../../../app/model/releaseNotes"; import { terminalTabLabel } from "../../terminal/model/terminalTab"; +import { browserUrlLabel } from "../../browser/model/browserUrl"; import { useLockOverscroll } from "../../../shared/hooks/useLockOverscroll"; import { useAnimatedReorder } from "../../../shared/hooks/useAnimatedReorder"; import { useTabCloseMotion } from "../hooks/useTabCloseMotion"; @@ -144,6 +147,16 @@ export function surfaceTabPresentation( }; } + if (isBrowserTab(file)) { + const name = file.browser.title?.trim() || browserUrlLabel(file.browser.url); + return { + name, + label: name, + iconName: "BROWSER", + tooltip: file.browser.url, + }; + } + if (isAgentTab(file)) { const name = file.path.trim() || "Agent"; return { @@ -302,6 +315,7 @@ export function SurfaceTabs({ const commit = isCommitTab(file); const review = isReviewTab(file) && !changes; const terminal = isTerminalTab(file); + const browser = isBrowserTab(file) ? file.browser : null; const agent = isAgentTab(file) ? file.agent : null; const { label, iconName, tooltip } = surfaceTabPresentation(file); const tab = ( @@ -371,6 +385,11 @@ export function SurfaceTabs({ > {terminal ? ( + ) : browser ? ( + ) : agent ? ( { let setup: SessionSetupResult | undefined; let acpSessionId: string | undefined; let didLoad = false; + const mcpServers = await acpBrowserMcpServersFor(input.sessionId); + if (retired()) throw new Error("Antigravity session stopped during startup"); if (canLoad && resume) { try { setup = await acp.request( "session/resume", - { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers: [] }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; @@ -512,7 +515,7 @@ async function startLive(input: SendTurnInput, life: number): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -533,7 +536,7 @@ async function startLive(input: SendTurnInput, life: number): Promise { const droppedBinding = canLoad && resume != null; setup = await acp.request( "session/new", - { cwd: input.cwd, mcpServers: [] }, + { cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup); diff --git a/src/integrations/harness/providers/claude/claude.ts b/src/integrations/harness/providers/claude/claude.ts index e20185e478..14c17574fd 100644 --- a/src/integrations/harness/providers/claude/claude.ts +++ b/src/integrations/harness/providers/claude/claude.ts @@ -1,4 +1,8 @@ import { TurnNotReadyError } from "../../core/types"; +import { + browserMcpFor, + claudeBrowserMcpConfig, +} from "../../../../features/browser/model/browserMcp"; import { nativeModelId } from "../../../../features/sessions/model/models"; import { sameProviderAccountId } from "../../../../features/providers/model/providerAccounts"; import type { @@ -538,10 +542,14 @@ async function ensureLive(input: HarnessSessionInput): Promise { }, ); + const browserMcp = await browserMcpFor(input.sessionId); await spawnChild( input.sessionId, path, - buildClaudeSpawnArgs(launch), + buildClaudeSpawnArgs({ + ...launch, + mcpConfig: browserMcp ? claudeBrowserMcpConfig(browserMcp) : undefined, + }), input.cwd, { provider: "claude", id: input.providerAccountId ?? "default" }, "claude", diff --git a/src/integrations/harness/providers/claude/claudeProtocol.ts b/src/integrations/harness/providers/claude/claudeProtocol.ts index e744af44a5..df61d94947 100644 --- a/src/integrations/harness/providers/claude/claudeProtocol.ts +++ b/src/integrations/harness/providers/claude/claudeProtocol.ts @@ -250,6 +250,8 @@ export function buildClaudeSpawnArgs(input: { includePartialMessages?: boolean; maxTurns?: number; isolated?: boolean; + /** Extra `--mcp-config` (MonoCode's browser), added to the user's servers. */ + mcpConfig?: string; }): string[] { const args = [ "--output-format", @@ -279,6 +281,7 @@ export function buildClaudeSpawnArgs(input: { } else { args.push(`--setting-sources=${CLAUDE_SETTING_SOURCES}`); args.push("--settings", JSON.stringify(settings)); + if (input.mcpConfig) args.push("--mcp-config", input.mcpConfig); } if (input.model) args.push("--model", input.model); if (input.effort) args.push("--effort", input.effort); diff --git a/src/integrations/harness/providers/codex/codex.ts b/src/integrations/harness/providers/codex/codex.ts index bec3cf89d2..a894f6535d 100644 --- a/src/integrations/harness/providers/codex/codex.ts +++ b/src/integrations/harness/providers/codex/codex.ts @@ -1,4 +1,9 @@ import { TurnNotReadyError } from "../../core/types"; +import { + BROWSER_AGENT_CONTEXT, + browserMcpFor, + codexBrowserMcpArgs, +} from "../../../../features/browser/model/browserMcp"; import { nativeModelId } from "../../../../features/sessions/model/models"; import { sameProviderAccountId } from "../../../../features/providers/model/providerAccounts"; import { @@ -92,6 +97,7 @@ type Live = { providerAccountId?: string; /** Thread-level network policy used when this app-server opened the thread. */ controlsAgents: boolean; + browserAvailable: boolean; ephemeral: boolean; codexStore?: "mono"; runtimeMode: RuntimeMode; @@ -289,7 +295,11 @@ export async function steerCodexTurn(input: SteerTurnInput): Promise { const params = buildTurnSteerParams({ threadId: live.threadId, expectedTurnId: turnId, - prompt: input.text.trim() || undefined, + prompt: input.text.trim() + ? live.browserAvailable + ? `${BROWSER_AGENT_CONTEXT}\n\n${input.text.trim()}` + : input.text.trim() + : undefined, attachments: input.attachments, }); if ( @@ -497,6 +507,8 @@ export function hasLiveCodexSession( } async function ensureLive(input: HarnessSessionInput): Promise { + const browserMcp = await browserMcpFor(input.sessionId); + const browserAvailable = browserMcp !== null; const existing = liveByThread.get(input.sessionId); const controlsAgents = input.controlsAgents === true; const ephemeral = @@ -516,6 +528,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { input.providerAccountId, ) && existing.controlsAgents === controlsAgents && + existing.browserAvailable === browserAvailable && existing.ephemeral === ephemeral && existing.codexStore === codexStore ) { @@ -525,7 +538,8 @@ async function ensureLive(input: HarnessSessionInput): Promise { if (existing) { // Codex may retain the thread's sandbox network policy across turns. // Switch it when this session gains /operator access or loses agent - // control, so its local CLI socket matches the current policy. + // control, so its local CLI socket matches the current policy. Browser + // access changes also need a new process to update the MCP configuration. if ( existing.cwd !== input.cwd || !sameProviderAccountId( @@ -650,7 +664,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { await spawnChild( input.sessionId, path, - ["app-server"], + ["app-server", ...(browserMcp ? codexBrowserMcpArgs(browserMcp) : [])], input.cwd, { provider: "codex", @@ -761,6 +775,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { cwd: input.cwd, providerAccountId: input.providerAccountId, controlsAgents, + browserAvailable, ephemeral, codexStore, runtimeMode: input.runtimeMode, @@ -821,7 +836,11 @@ async function runTurn(live: Live, input: SendTurnInput): Promise { threadId: live.threadId, runtimeMode: input.runtimeMode, controlsAgents: input.controlsAgents, - prompt: input.text.trim() || undefined, + prompt: input.text.trim() + ? live.browserAvailable + ? `${BROWSER_AGENT_CONTEXT}\n\n${input.text.trim()}` + : input.text.trim() + : undefined, attachments: input.attachments, model, effort, diff --git a/src/integrations/harness/providers/codex/codexLive.test.ts b/src/integrations/harness/providers/codex/codexLive.test.ts index 5d24d4e909..70ae97dfa1 100644 --- a/src/integrations/harness/providers/codex/codexLive.test.ts +++ b/src/integrations/harness/providers/codex/codexLive.test.ts @@ -1,5 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const browserMcpFor = vi.hoisted(() => + vi.fn( + async (): Promise<{ + command: string; + args: string[]; + env: Record; + } | null> => null, + ), +); +vi.mock( + "../../../../features/browser/model/browserMcp", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../features/browser/model/browserMcp") + >()), + browserMcpFor, + }), +); + const sent: string[] = []; let onLine: ((line: string) => void) | undefined; const writeChild = vi.fn(async (_id: string, line: string) => { @@ -118,6 +137,7 @@ async function startTurn( beforeThreadReply?: () => Promise; onAccepted?: () => void; controlsAgents?: boolean; + text?: string; ephemeral?: boolean; codexStore?: "mono"; } = {}, @@ -142,7 +162,7 @@ async function startTurn( ephemeral: options.ephemeral, codexStore: options.codexStore, intent: options.intent, - text: "summarize the changelog", + text: options.text ?? "summarize the changelog", attachments: [], onAccepted: options.onAccepted, onEvent: (event) => events.push(event), @@ -177,6 +197,7 @@ async function startTurn( describe("codex live turn sequence", () => { beforeEach(() => { + browserMcpFor.mockResolvedValue(null); sent.length = 0; onLine = undefined; writeChild.mockClear(); @@ -194,6 +215,30 @@ describe("codex live turn sequence", () => { __codexTestReset(); }); + it.each([false, true])( + "routes embedded browser requests on resumed=%s threads", + async (resume) => { + browserMcpFor.mockResolvedValue({ + command: "/fake/monocode", + args: ["browser-mcp"], + env: {}, + }); + const text = + "open google.com in your embedded browser, search for MonoCode, and click I\'m feeling lucky."; + const { turn } = await startTurn("codex-live", { resume, text }); + const params = parse().find((m) => m.method === "turn/start")!.params as { + input: { text: string }[]; + }; + expect(params.input[0].text).toContain("monocode_browser MCP server"); + expect(params.input[0].text).toContain( + "separate from Computer Use's iab", + ); + expect(params.input[0].text).toContain(text); + notify("turn/completed", { turn: { id: "turn_1", status: "completed" } }); + await turn; + }, + ); + it("reports when the provider accepts a turn", async () => { const onAccepted = vi.fn(); const { turn } = await startTurn("codex-live", { onAccepted }); diff --git a/src/integrations/harness/providers/cursor/cursor.ts b/src/integrations/harness/providers/cursor/cursor.ts index 302184cc0a..bc05448ff5 100644 --- a/src/integrations/harness/providers/cursor/cursor.ts +++ b/src/integrations/harness/providers/cursor/cursor.ts @@ -1,4 +1,5 @@ import { TurnNotReadyError } from "../../core/types"; +import { acpBrowserMcpServersFor } from "../../../../features/browser/model/browserMcp"; import { nativeModelId } from "../../../../features/sessions/model/models"; import { AcpSubagents } from "../../core/acpSubagents"; import type { RuntimeMode } from "../../../../features/sessions/model/session"; @@ -331,7 +332,7 @@ async function ensureLive(input: SendTurnInput): Promise { setup = await acp.request("session/load", { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers: await acpBrowserMcpServersFor(input.sessionId), }); acpSessionId = resume.acpSessionId; didLoad = true; @@ -347,7 +348,7 @@ async function ensureLive(input: SendTurnInput): Promise { if (!acpSessionId) { setup = await acp.request("session/new", { cwd: input.cwd, - mcpServers: [], + mcpServers: await acpBrowserMcpServersFor(input.sessionId), }); acpSessionId = setup.sessionId?.trim(); } diff --git a/src/integrations/harness/providers/fx/fx.ts b/src/integrations/harness/providers/fx/fx.ts index 85e4b9ec8e..ba7636d55a 100644 --- a/src/integrations/harness/providers/fx/fx.ts +++ b/src/integrations/harness/providers/fx/fx.ts @@ -1,4 +1,5 @@ import { nativeModelId } from "../../../../features/sessions/model/models"; +import { acpBrowserMcpServersFor } from "../../../../features/browser/model/browserMcp"; import { AcpSubagents } from "../../core/acpSubagents"; import type { RuntimeMode } from "../../../../features/sessions/model/session"; import { AcpClient, type AcpHandlers } from "../../core/acp"; @@ -287,12 +288,13 @@ async function ensureLive(input: SendTurnInput): Promise { let setup: SessionSetupResult | undefined; let acpSessionId: string | undefined; let didLoad = false; + const mcpServers = await acpBrowserMcpServersFor(input.sessionId); if (canLoad && resume) { try { setup = await acp.request( "session/resume", - { sessionId: resume.acpSessionId }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; @@ -305,7 +307,7 @@ async function ensureLive(input: SendTurnInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -324,7 +326,7 @@ async function ensureLive(input: SendTurnInput): Promise { if (!acpSessionId) { setup = await acp.request( "session/new", - { cwd: input.cwd, mcpServers: [] }, + { cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup); diff --git a/src/integrations/harness/providers/fx/fxLive.test.ts b/src/integrations/harness/providers/fx/fxLive.test.ts index 37164291d9..0eb0c906ee 100644 --- a/src/integrations/harness/providers/fx/fxLive.test.ts +++ b/src/integrations/harness/providers/fx/fxLive.test.ts @@ -22,7 +22,7 @@ vi.mock("../../core/child", () => ({ }, })); -const { sendFxTurn, stopFxSession } = await import("./fx"); +const { bindFxSession, sendFxTurn, stopFxSession } = await import("./fx"); import type { HarnessEvent } from "../../core/types"; function reply(id: number, result: unknown) { @@ -53,6 +53,36 @@ describe("fx live turn sequence", () => { sent.length = 0; }); + it("resumes a saved session with its cwd and MCP servers", async () => { + bindFxSession("t-resume", "S9", "/repo"); + const turn = sendFxTurn({ + sessionId: "t-resume", + cwd: "/repo", + model: "fx:zai/glm-5.2", + modelSettings: {}, + runtimeMode: "supervised", + text: "hey", + attachments: [], + onEvent: () => undefined, + } as never); + turn.catch(() => undefined); + await waitFor( + () => parse().some((m) => m.method === "initialize"), + "initialize", + ); + reply(parse().find((m) => m.method === "initialize")!.id, { + protocolVersion: 1, + }); + await waitFor( + () => parse().some((m) => m.method === "session/resume"), + "session/resume", + ); + const resume = parse().find((m) => m.method === "session/resume")!; + expect(resume.params).toMatchObject({ sessionId: "S9", cwd: "/repo" }); + expect(Array.isArray(resume.params.mcpServers)).toBe(true); + await stopFxSession("t-resume"); + }); + it("auto-approves a permission request instead of blocking the turn", async () => { const events: HarnessEvent[] = []; const input = { diff --git a/src/integrations/harness/providers/grok/grok.ts b/src/integrations/harness/providers/grok/grok.ts index 67788ad910..8dc3fc225e 100644 --- a/src/integrations/harness/providers/grok/grok.ts +++ b/src/integrations/harness/providers/grok/grok.ts @@ -1,4 +1,5 @@ import { nativeModelId } from "../../../../features/sessions/model/models"; +import { acpBrowserMcpServersFor } from "../../../../features/browser/model/browserMcp"; import { AcpSubagents } from "../../core/acpSubagents"; import type { RuntimeMode } from "../../../../features/sessions/model/session"; import { AcpClient, type AcpHandlers } from "../../core/acp"; @@ -334,12 +335,13 @@ async function ensureLive(input: HarnessSessionInput): Promise { let setup: unknown; let acpSessionId: string | undefined; let didLoad = false; + const mcpServers = await acpBrowserMcpServersFor(input.sessionId); if (canLoad && resume) { try { setup = await acp.request( "session/resume", - { sessionId: resume.acpSessionId }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; @@ -352,7 +354,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -372,7 +374,11 @@ async function ensureLive(input: HarnessSessionInput): Promise { try { setup = await acp.request( "session/new", - grokSessionNewParams(input.cwd, input.runtimeMode), + grokSessionNewParams( + input.cwd, + input.runtimeMode, + mcpServers, + ), SESSION_TIMEOUT_MS, ); } catch (error) { diff --git a/src/integrations/harness/providers/grok/grokLive.test.ts b/src/integrations/harness/providers/grok/grokLive.test.ts index 6aba7f179b..7e5ad12dac 100644 --- a/src/integrations/harness/providers/grok/grokLive.test.ts +++ b/src/integrations/harness/providers/grok/grokLive.test.ts @@ -23,6 +23,7 @@ vi.mock("../../core/child", () => ({ })); const { + bindGrokSession, compactGrokContext, sendGrokTurn, respondGrokApproval, @@ -88,6 +89,39 @@ describe("grok live turn sequence", () => { sent.length = 0; }); + it("resumes a saved session with its cwd and MCP servers", async () => { + bindGrokSession("g-resume", "S9", "/repo"); + const turn = sendGrokTurn({ + sessionId: "g-resume", + cwd: "/repo", + model: "grok-4.6", + modelSettings: {}, + runtimeMode: "supervised", + text: "hey", + attachments: [], + onEvent: () => undefined, + } as never); + turn.catch(() => undefined); + await waitFor( + () => parse().some((m) => m.method === "initialize"), + "initialize", + ); + reply(parse().find((m) => m.method === "initialize")!.id, initResult); + await waitFor( + () => parse().some((m) => m.method === "authenticate"), + "authenticate", + ); + reply(parse().find((m) => m.method === "authenticate")!.id, {}); + await waitFor( + () => parse().some((m) => m.method === "session/resume"), + "session/resume", + ); + const resume = parse().find((m) => m.method === "session/resume")!; + expect(resume.params).toMatchObject({ sessionId: "S9", cwd: "/repo" }); + expect(Array.isArray(resume.params.mcpServers)).toBe(true); + await stopGrokSession("g-resume"); + }); + it("authenticates, selects the model, and prompts", async () => { const events: HarnessEvent[] = []; const turn = sendGrokTurn({ diff --git a/src/integrations/harness/providers/grok/grokProtocol.ts b/src/integrations/harness/providers/grok/grokProtocol.ts index bb231c55ce..c830ee253e 100644 --- a/src/integrations/harness/providers/grok/grokProtocol.ts +++ b/src/integrations/harness/providers/grok/grokProtocol.ts @@ -122,8 +122,9 @@ export function grokTextSpawnArgs(): string[] { export function grokSessionNewParams( cwd: string, runtimeMode: RuntimeMode, + mcpServers: unknown[] = [], ): Record { - const params: Record = { cwd, mcpServers: [] }; + const params: Record = { cwd, mcpServers }; if (runtimeMode === "full-access") { params._meta = { yoloMode: true }; } else if (runtimeMode === "auto") { diff --git a/src/integrations/harness/providers/hermes/hermes.ts b/src/integrations/harness/providers/hermes/hermes.ts index e62db66d1d..e767983c52 100644 --- a/src/integrations/harness/providers/hermes/hermes.ts +++ b/src/integrations/harness/providers/hermes/hermes.ts @@ -1,4 +1,5 @@ import { TurnNotReadyError } from "../../core/types"; +import { acpBrowserMcpServersFor } from "../../../../features/browser/model/browserMcp"; import { nativeModelId } from "../../../../features/sessions/model/models"; import type { RuntimeMode } from "../../../../features/sessions/model/session"; import { AcpClient, type AcpHandlers } from "../../core/acp"; @@ -283,7 +284,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { try { setup = await acp.request( "session/load", - { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers: [] }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers: await acpBrowserMcpServersFor(input.sessionId) }, SESSION_TIMEOUT_MS, ); acpSessionId = hermesSessionId(setup) ?? resume.acpSessionId; @@ -300,7 +301,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { try { setup = await acp.request( "session/new", - { cwd: input.cwd, mcpServers: [] }, + { cwd: input.cwd, mcpServers: await acpBrowserMcpServersFor(input.sessionId) }, SESSION_TIMEOUT_MS, ); } catch (error) { diff --git a/src/platform/tauri/browser.test.ts b/src/platform/tauri/browser.test.ts new file mode 100644 index 0000000000..67a0f80149 --- /dev/null +++ b/src/platform/tauri/browser.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const calls: Array<{ + args: { id: string; url: string }; + resolve: (value: { outcome: string }) => void; + reject: (reason: unknown) => void; +}> = []; +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn( + (_command: string, args: { id: string; url: string }) => + new Promise((resolve, reject) => calls.push({ args, resolve, reject })), + ), +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +import { navigateBrowser } from "./browser"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("navigateBrowser", () => { + beforeEach(() => { + calls.length = 0; + }); + + it("hands a tab's navigations to the native side one at a time, in order", async () => { + const first = navigateBrowser("t1", "https://a.test/#a"); + const second = navigateBrowser("t1", "https://a.test/#b"); + const third = navigateBrowser("t1", "https://a.test/#a"); + await flush(); + expect(calls.map((c) => c.args.url)).toEqual(["https://a.test/#a"]); + calls[0].resolve({ outcome: "applied" }); + await first; + await flush(); + expect(calls.map((c) => c.args.url)).toEqual([ + "https://a.test/#a", + "https://a.test/#b", + ]); + calls[1].resolve({ outcome: "applied" }); + await second; + await flush(); + calls[2].resolve({ outcome: "document" }); + await expect(third).resolves.toEqual({ outcome: "document" }); + expect(calls.map((c) => c.args.url)).toEqual([ + "https://a.test/#a", + "https://a.test/#b", + "https://a.test/#a", + ]); + }); + + it("goes on after a rejected navigation", async () => { + const first = navigateBrowser("t2", "https://a.test/"); + const second = navigateBrowser("t2", "https://b.test/"); + await flush(); + calls[0].reject("blocked"); + await expect(first).rejects.toBe("blocked"); + await flush(); + expect(calls).toHaveLength(2); + calls[1].resolve({ outcome: "document" }); + await expect(second).resolves.toEqual({ outcome: "document" }); + }); + + it("does not make one tab wait for another", async () => { + void navigateBrowser("t3", "https://a.test/"); + void navigateBrowser("t4", "https://b.test/"); + await flush(); + expect(calls.map((c) => c.args.id).sort()).toEqual(["t3", "t4"]); + }); +}); diff --git a/src/platform/tauri/browser.ts b/src/platform/tauri/browser.ts new file mode 100644 index 0000000000..d5a765fc32 --- /dev/null +++ b/src/platform/tauri/browser.ts @@ -0,0 +1,138 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +export type BrowserBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +export type BrowserEvent = + | { kind: "load"; id: string; url: string; loading: boolean } + | { kind: "title"; id: string; title: string } + | { kind: "openTab"; id: string; url: string }; + +export function openBrowserView( + id: string, + url: string, + bounds: BrowserBounds, + visible: boolean, +): Promise { + return invoke("browser_open", { id, url, bounds, visible }); +} + +/** The page's current URL, including same-document (pushState, hash) changes. */ +export function readBrowserUrl(id: string): Promise { + return invoke("browser_url", { id }); +} + +export function setBrowserBounds( + id: string, + bounds: BrowserBounds, +): Promise { + return invoke("browser_set_bounds", { id, bounds }); +} + +export function setBrowserVisible(id: string, visible: boolean): Promise { + return invoke("browser_set_visible", { id, visible }); +} + +export function focusBrowserView(id: string): Promise { + return invoke("browser_focus", { id }); +} + +export type BrowserNavigation = { + /** + * - applied: the page stayed in its document; no load follows, and `url` is + * where its synchronous handlers left it. + * - document: the webview was told to load a document; load events report + * what happens next, though one that fails before committing may report none. + * - unknown: not known whether a load follows (the page refused the target, + * replaced it with a load not yet committed, or did not answer). + */ + outcome: "applied" | "document" | "unknown"; + url?: string; +}; + +/** Per tab, the last navigation handed to the native side, settled or not. */ +const dispatching = new Map>(); + +/** + * Navigations for one tab reach the native side one at a time, so the page + * applies them in the order they were made whatever order replies come in. + * `onDispatch` runs when this navigation's turn comes, before it is sent: the + * moment to read state (such as load counters) that earlier navigations of the + * tab can still change while this one waits. A promise it returns holds the + * navigation, and the tab's later ones, until it settles. + */ +export function navigateBrowser( + id: string, + url: string, + onDispatch?: () => void | Promise, +): Promise { + const turn = (dispatching.get(id) ?? Promise.resolve()).then(async () => { + await onDispatch?.(); + return invoke("browser_navigate", { id, url }); + }); + const tail = turn.then( + () => undefined, + () => undefined, + ); + dispatching.set(id, tail); + void tail.then(() => { + if (dispatching.get(id) === tail) dispatching.delete(id); + }); + return turn; +} + +export function browserHistory( + id: string, + action: "back" | "forward" | "reload" | "stop", +): Promise { + return invoke("browser_history", { id, action }); +} + +export function closeBrowserView(id: string): Promise { + return invoke("browser_close", { id }); +} + +/** Close this window's native tabs that are not in `ids`. */ +export function retainBrowserViews(ids: string[]): Promise { + return invoke("browser_retain", { ids }); +} + +export function onBrowserEvent( + handler: (event: BrowserEvent) => void, +): Promise { + return listen("browser-event", (event) => + handler(event.payload), + ); +} + +/** Run an agent script in a tab; resolves with its JSON result. */ +export function evalInBrowser( + id: string, + script: string, + timeoutMs?: number, +): Promise { + return invoke("browser_eval", { id, script, timeoutMs }); +} + +/** The visible part of a tab as base64 PNG. */ +export function screenshotBrowser(id: string): Promise { + return invoke("browser_screenshot", { id }); +} + +/** How a provider launches the browser MCP server for a session. */ +export type BrowserMcpLaunch = { + command: string; + args: string[]; + env: Record; +}; + +export function browserMcpLaunch( + sessionId: string, +): Promise { + return invoke("control_browser_mcp", { sessionId }); +} diff --git a/vite.config.ts b/vite.config.ts index 07be2f4153..3b89628d56 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -// @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; export default defineConfig(async ({ mode }) => {