From f68f59656b298be4976c3a9f08ece8e0a38b3c92 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 21:13:49 -0400 Subject: [PATCH 01/24] Add embedded browser dock A Browser toggle in the status bar opens a per-project browser dock, docked right by default. It reuses the terminal dock model and a shared DockPanel frame, so tabs, reordering, moving sides, resizing and hiding behave the same. Each tab is a native child webview laid over a DOM placeholder. Child webviews make a window multi-webview, which Tauri no longer treats as a WebviewWindow, so workspace-window commands and lookups now use Window and Webview instead. Pages get no IPC, cannot load file: or app URLs, and hide while menus or dialogs overlap them. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.toml | 2 +- src-tauri/src/browser.rs | 278 +++++++++++++ src-tauri/src/control.rs | 12 +- src-tauri/src/lib.rs | 19 +- src-tauri/src/macos.rs | 40 +- src-tauri/src/quick_composer.rs | 3 +- src-tauri/src/reminders.rs | 12 +- src-tauri/src/window.rs | 45 ++- src/app/App.tsx | 15 + src/app/shell/UsageFooter.tsx | 6 +- .../browser/hooks/useOverlayOcclusion.ts | 69 ++++ .../browser/model/browserStore.test.ts | 116 ++++++ src/features/browser/model/browserStore.ts | 376 ++++++++++++++++++ src/features/browser/model/browserUrl.test.ts | 55 +++ src/features/browser/model/browserUrl.ts | 60 +++ .../browser/ui/BrowserStatusButton.tsx | 25 ++ src/features/browser/ui/BrowserView.tsx | 260 ++++++++++++ .../browser/ui/ProjectBrowserDock.tsx | 194 +++++++++ .../terminal/ui/ProjectTerminalDock.tsx | 280 ++----------- src/features/workspace/model/layout.ts | 20 +- src/features/workspace/ui/DockPanel.tsx | 293 ++++++++++++++ src/features/workspace/ui/SurfaceTabs.tsx | 19 + src/platform/tauri/browser.ts | 66 +++ 23 files changed, 1959 insertions(+), 306 deletions(-) create mode 100644 src-tauri/src/browser.rs create mode 100644 src/features/browser/hooks/useOverlayOcclusion.ts create mode 100644 src/features/browser/model/browserStore.test.ts create mode 100644 src/features/browser/model/browserStore.ts create mode 100644 src/features/browser/model/browserUrl.test.ts create mode 100644 src/features/browser/model/browserUrl.ts create mode 100644 src/features/browser/ui/BrowserStatusButton.tsx create mode 100644 src/features/browser/ui/BrowserView.tsx create mode 100644 src/features/browser/ui/ProjectBrowserDock.tsx create mode 100644 src/features/workspace/ui/DockPanel.tsx create mode 100644 src/platform/tauri/browser.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9bec4f8695..64efdac344 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 diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs new file mode 100644 index 0000000000..1f1c597b1a --- /dev/null +++ b/src-tauri/src/browser.rs @@ -0,0 +1,278 @@ +//! 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 serde::{Deserialize, Serialize}; +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"; + +#[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}")), + } +} + +/// In dev the app itself is served from localhost. A tab pointed there would +/// load the app with the app's own IPC permissions, so refuse that origin. +fn is_app_origin(app: &AppHandle, url: &Url) -> bool { + if !cfg!(debug_assertions) { + return false; + } + app.config() + .build + .dev_url + .as_ref() + .is_some_and(|dev| dev.origin() == url.origin()) +} + +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()) +} + +#[tauri::command] +pub fn browser_open( + app: AppHandle, + window: Window, + id: String, + url: String, + bounds: Bounds, + visible: bool, +) -> Result<(), String> { + let label = label(&id)?; + 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| !is_app_origin(&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); + 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(()) +} + +#[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()) +} + +#[tauri::command] +pub fn browser_navigate(app: AppHandle, id: String, url: String) -> Result<(), String> { + let url = parse_url(&url)?; + if is_app_origin(&app, &url) { + return Err("That address serves MonoCode itself".into()); + } + find(&app, &id)?.navigate(url).map_err(|e| e.to_string()) +} + +#[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(()), + } +} + +/// 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 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 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/control.rs b/src-tauri/src/control.rs index db000c7247..38036144f8 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -10,7 +10,7 @@ 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 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."; @@ -283,7 +283,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 +333,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 +356,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 +404,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, @@ -493,7 +493,7 @@ pub fn app_cli_path() -> Result { #[tauri::command] pub fn control_reply( - window: WebviewWindow, + window: Window, host: State<'_, ControlHost>, id: String, response: Value, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 075e824f94..ee90f12c1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use tauri::Manager; mod account_identity; mod automations; mod azure_devops; +mod browser; mod chat_background; mod checkpoint; mod control; @@ -175,7 +176,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")] @@ -184,7 +185,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")] @@ -193,7 +194,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")] @@ -243,13 +244,13 @@ pub fn run() { { quick_composer::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); } @@ -260,6 +261,14 @@ pub fn run() { menu::dispatch(app, event.id().as_ref()); }) .invoke_handler(tauri::generate_handler![ + browser::browser_open, + browser::browser_set_bounds, + browser::browser_set_visible, + browser::browser_focus, + browser::browser_navigate, + browser::browser_history, + browser::browser_close, + browser::browser_retain, remote::remote_machines, remote::remote_connect, remote::remote_disconnect, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index f7f0b45102..c6f5749802 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -42,7 +42,7 @@ use objc2_app_kit::{ }; use objc2_foundation::NSString; 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; @@ -73,7 +73,7 @@ unsafe extern "C" { fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; } -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); @@ -97,7 +97,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() { @@ -147,7 +147,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; }; @@ -158,7 +158,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) { @@ -170,14 +170,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()); @@ -189,7 +189,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; @@ -199,7 +199,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; }; @@ -215,11 +215,7 @@ fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { /// 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)); @@ -229,17 +225,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; }; @@ -307,7 +299,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) { let Some(ns_window) = ns_window(window) else { return; }; @@ -326,7 +318,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); }; @@ -339,14 +331,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/quick_composer.rs b/src-tauri/src/quick_composer.rs index e8a0a70d02..3e65e682d3 100644 --- a/src-tauri/src/quick_composer.rs +++ b/src-tauri/src/quick_composer.rs @@ -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; @@ -469,7 +470,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 24e757f169..b082ebc293 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); @@ -110,7 +112,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 { - let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); + set_background_color(Some(Color(0, 0, 0, 3))); return crate::macos::enable_glass(&window, background, opacity); } else { crate::macos::disable_glass(&window, background.r, background.g, background.b); @@ -161,18 +170,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() @@ -180,7 +189,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; @@ -191,14 +200,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()) } @@ -207,9 +216,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(); @@ -385,7 +394,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); @@ -403,7 +412,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(); @@ -413,7 +422,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 }; @@ -435,7 +444,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. @@ -534,7 +543,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 d8c1c50de2..e62ec0e6db 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -623,6 +623,8 @@ 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/ProjectBrowserDock"; + import { lazySurface } from "../shared/ui/lazySurface"; import { preloadNavigationWhenIdle } from "./model/preloadNavigation"; import { requestTranscriptJump } from "../features/sessions/model/transcriptJump"; @@ -12188,6 +12190,17 @@ function Workspace({ {compactTitleBar ? null : workspaceTitleBar}
+ {[...linkedWorkItemPanels.values()].map((panel) => ( 0 } + showBrowser={!monoCovers} /> )} diff --git a/src/app/shell/UsageFooter.tsx b/src/app/shell/UsageFooter.tsx index dfa127c56f..37b9eaf596 100644 --- a/src/app/shell/UsageFooter.tsx +++ b/src/app/shell/UsageFooter.tsx @@ -1,4 +1,5 @@ import { RefreshCw, Terminal } from "../../shared/ui/icons"; +import { BrowserStatusButton } from "../../features/browser/ui/BrowserStatusButton"; import { useCallback, useEffect, useRef, useState } from "react"; import { HarnessIcon } from "../../features/sessions/ui/HarnessIcon"; import { Popover, type PopoverDismissReason } from "../../shared/ui/Popover"; @@ -65,6 +66,7 @@ export function UsageFooter({ onNewTerminal, onShowTerminal, projectTerminalActive = false, + showBrowser = false, onSelectAccount, onManageAccounts, }: { @@ -77,6 +79,7 @@ export function UsageFooter({ onNewTerminal?: () => void; onShowTerminal?: () => void; projectTerminalActive?: boolean; + showBrowser?: boolean; onSelectAccount?: ( provider: ProviderAccountProvider, accountId: string, @@ -369,8 +372,9 @@ export function UsageFooter({ ) : session ? ( ) : 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/browserStore.test.ts b/src/features/browser/model/browserStore.test.ts new file mode 100644 index 0000000000..4ac36ac362 --- /dev/null +++ b/src/features/browser/model/browserStore.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + addBrowserTab, + newBrowserTab, + parseBrowserState, + patchBrowserTabIn, + serializeBrowserState, + toggleBrowserDock, + type BrowserState, +} from "./browserStore"; + +const empty: BrowserState = { + docks: [], + projectPath: "/work/app", + lastSide: "right", +}; + +describe("browser docks", () => { + 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({ + projectPath: "/work/app", + 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("opens a page's new window right after its opener", () => { + const a = newBrowserTab("https://a.test/", "/work/app"); + const b = newBrowserTab("https://b.test/", "/work/app"); + const c = newBrowserTab("https://c.test/", "/work/app"); + let state = addBrowserTab(empty, a); + state = addBrowserTab(state, b); + state = addBrowserTab(state, c, undefined, { 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("keeps each project's tabs in its own dock", () => { + const a = newBrowserTab("https://a.test/", "/work/app"); + const b = newBrowserTab("https://b.test/", "/work/other"); + const state = addBrowserTab(addBrowserTab(empty, a), b, "/work/other"); + expect(state.docks.map((dock) => dock.projectPath)).toEqual([ + "/work/app", + "/work/other", + ]); + }); + + it("patches page state and leaves unchanged tabs alone", () => { + const tab = newBrowserTab("https://a.test/", "/work/app"); + const state = addBrowserTab(empty, 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); + }); +}); + +describe("browser persistence", () => { + it("round-trips docks without live page state", () => { + const tab = newBrowserTab("https://a.test/", "/work/app"); + const state = patchBrowserTabIn(addBrowserTab(empty, tab), tab.id, { + title: "A", + loading: true, + }); + const restored = parseBrowserState(serializeBrowserState(state)); + expect(restored.lastSide).toBe("right"); + 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: [ + { projectPath: "/a", side: "middle", pane: { files: [] } }, + { + projectPath: "/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: "/b", 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"); + }); +}); diff --git a/src/features/browser/model/browserStore.ts b/src/features/browser/model/browserStore.ts new file mode 100644 index 0000000000..dfa200eec3 --- /dev/null +++ b/src/features/browser/model/browserStore.ts @@ -0,0 +1,376 @@ +import { useSyncExternalStore } from "react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + addTerminalToDock, + clampDockSize, + closeTerminalInDock, + createProjectTerminal, + findProjectTerminal, + isDockSide, + mapProjectTerminal, + reorderDockTerminals, + selectDockTerminal, + withDockOpen, + withDockSide, + withDockSize, + type DockSide, + type ProjectTerminalDock, +} from "../../projects/model/projectTerminal"; +import { normalizeProjectPath } from "../../projects/model/recents"; +import type { + BrowserTabSource, + FilePaneTab, +} from "../../workspace/model/layout"; +import { BLANK_URL } from "./browserUrl"; + +/** Browser docks reuse the terminal dock model: per project, one pane of tabs. */ +export type BrowserDock = ProjectTerminalDock; + +export type BrowserState = { + docks: BrowserDock[]; + /** Project the window is showing; new tabs land in its dock. */ + projectPath: string; + lastSide: DockSide; +}; + +export const BROWSER_DEFAULT_SIDE: DockSide = "right"; +const DEFAULT_SIZE = { horizontal: 520, vertical: 320 }; +const STORAGE_PREFIX = "monocode.browser.v1:"; + +export function browserDefaultSize(side: DockSide): number { + return side === "left" || side === "right" + ? DEFAULT_SIZE.horizontal + : DEFAULT_SIZE.vertical; +} + +export function newBrowserTab(url: string, cwd: 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 addBrowserTab( + state: BrowserState, + file: FilePaneTab, + projectPath = state.projectPath, + options: { afterId?: string } = {}, +): BrowserState { + const existing = findProjectTerminal(state.docks, projectPath); + if (!existing) { + const dock = createProjectTerminal(projectPath, file, state.lastSide); + return { + ...state, + docks: [ + ...state.docks, + { + ...dock, + size: clampDockSize( + dock.side, + browserDefaultSize(dock.side), + viewport(), + ), + }, + ], + }; + } + return { + ...state, + docks: mapProjectTerminal(state.docks, projectPath, (dock) => { + const next = addTerminalToDock(dock, file); + 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 } }; + }), + }; +} + +export function toggleBrowserDock(state: BrowserState): BrowserState { + const dock = findProjectTerminal(state.docks, state.projectPath); + if (!dock) { + return addBrowserTab(state, newBrowserTab(BLANK_URL, state.projectPath)); + } + return mapDock(state, (entry) => withDockOpen(entry, !entry.open)); +} + +export function patchBrowserTabIn( + state: BrowserState, + fileId: string, + patch: Partial, +): BrowserState { + let changed = false; + const docks = state.docks.map((dock) => { + if (!dock.pane.files.some((file) => file.id === fileId)) return dock; + 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 + ) { + return file; + } + changed = true; + return { ...file, path: browser.url, browser }; + }); + return { ...dock, pane: { ...dock.pane, files } }; + }); + return changed ? { ...state, docks } : state; +} + +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, + update: (dock: BrowserDock) => BrowserDock | null, + projectPath = state.projectPath, +): BrowserState { + const docks = mapProjectTerminal(state.docks, projectPath, update); + return docks === state.docks ? state : { ...state, docks }; +} + +// ---- 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.projectPath !== "string") 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 [ + { + projectPath: dock.projectPath, + 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: each window keeps its own docks, like its terminals. +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, + projectPath: "", + lastSide: stored.lastSide ?? BROWSER_DEFAULT_SIDE, + }; +} + +let state: BrowserState | null = null; +const listeners = new Set<() => void>(); +let saveTimer: ReturnType | undefined; + +export function getBrowserState(): BrowserState { + state ??= initialState(); + return state; +} + +function setState(next: BrowserState) { + if (next === getBrowserState()) return; + state = next; + for (const listener of listeners) listener(); + 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 setBrowserProject(projectPath: string) { + const path = projectPath ? normalizeProjectPath(projectPath) : ""; + update((current) => + current.projectPath === path ? current : { ...current, projectPath: path }, + ); +} + +export function toggleBrowser() { + update(toggleBrowserDock); +} + +export function showBrowser() { + update((current) => + findProjectTerminal(current.docks, current.projectPath) + ? mapDock(current, (dock) => withDockOpen(dock, true)) + : toggleBrowserDock(current), + ); +} + +export function hideBrowser() { + update((current) => mapDock(current, (dock) => withDockOpen(dock, false))); +} + +/** Open `url` in a new tab of the current project's browser and show it. */ +export function openBrowserTab( + url: string = BLANK_URL, + options: { projectPath?: string; afterId?: string } = {}, +): string { + const projectPath = options.projectPath ?? getBrowserState().projectPath; + const file = newBrowserTab(url, projectPath); + update((current) => + mapDock( + addBrowserTab(current, file, projectPath, options), + (dock) => withDockOpen(dock, true), + projectPath, + ), + ); + return file.id; +} + +export function selectBrowserTab(fileId: string) { + update((current) => mapDock(current, (dock) => selectDockTerminal(dock, fileId))); +} + +export function closeBrowserTab(fileId: string) { + update((current) => { + const dock = dockOfTab(current, fileId); + if (!dock) return current; + return mapDock( + current, + (entry) => closeTerminalInDock(entry, fileId), + dock.projectPath, + ); + }); +} + +export function closeOtherBrowserTabs(fileId: string) { + update((current) => + mapDock(current, (dock) => { + const keep = dock.pane.files.filter((file) => file.id === fileId); + if (keep.length === 0) return dock; + return { ...dock, pane: { ...dock.pane, files: keep, activeFileId: fileId } }; + }), + ); +} + +export function reorderBrowserTabs(ids: string[]) { + update((current) => + mapDock(current, (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, (dock) => withDockSide(dock, side, viewport())), + lastSide: side, + })); +} + +export function setBrowserSize(size: number) { + update((current) => + mapDock(current, (dock) => withDockSize(dock, size, viewport())), + ); +} + +export function patchBrowserTab( + fileId: string, + patch: Partial, +) { + update((current) => patchBrowserTabIn(current, fileId, patch)); +} + +/** Every native tab id this window should keep alive. */ +export function browserTabIds(current = getBrowserState()): string[] { + return current.docks.flatMap((dock) => dock.pane.files.map((file) => file.id)); +} 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..00eae3ba03 --- /dev/null +++ b/src/features/browser/model/browserUrl.ts @@ -0,0 +1,60 @@ +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/ui/BrowserStatusButton.tsx b/src/features/browser/ui/BrowserStatusButton.tsx new file mode 100644 index 0000000000..9526d83df3 --- /dev/null +++ b/src/features/browser/ui/BrowserStatusButton.tsx @@ -0,0 +1,25 @@ +import { Globe } from "../../../shared/ui/icons"; +import { findProjectTerminal } from "../../projects/model/projectTerminal"; +import { toggleBrowser, useBrowserState } from "../model/browserStore"; + +/** Status bar toggle for the current project's browser dock. */ +export function BrowserStatusButton() { + const state = useBrowserState(); + const open = !!findProjectTerminal(state.docks, state.projectPath)?.open; + 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..3ff136583b --- /dev/null +++ b/src/features/browser/ui/BrowserView.tsx @@ -0,0 +1,260 @@ +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, + navigateBrowser, + 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 { patchBrowserTab } from "../model/browserStore"; +import { useOverlayOcclusion } from "../hooks/useOverlayOcclusion"; + +type Props = { + id: string; + tab: BrowserTabSource; + /** Active tab of a dock that is on screen. */ + visible: boolean; + /** The dock sash is being dragged. */ + resizing: boolean; +}; + +/** + * React StrictMode and dock moves unmount and remount a view 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 + ); +} + +export function BrowserView({ id, tab, visible, resizing }: Props) { + const host = useRef(null); + const scale = useSyncExternalStore(subscribeUiScale, loadUiScale); + const covered = useOverlayOcclusion(host, visible); + const shown = visible && !covered; + const [ready, setReady] = useState(false); + const [address, setAddress] = useState(tab.url === BLANK_URL ? "" : tab.url); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + const placed = useRef(null); + const input = 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; + openBrowserView(id, tab.url, bounds, false) + .then(() => alive && setReady(true)) + .catch((reason: unknown) => alive && setError(String(reason))); + return () => { + alive = false; + setReady(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]); + + useEffect(() => { + if (!editing) setAddress(tab.url === BLANK_URL ? "" : tab.url); + }, [editing, tab.url]); + + useEffect(() => { + if (ready && visible && tab.url === BLANK_URL) input.current?.focus(); + // Only when a blank tab first appears. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, visible]); + + const go = (event: FormEvent) => { + event.preventDefault(); + const url = browserUrlFromInput(address); + setEditing(false); + setError(null); + input.current?.blur(); + patchBrowserTab(id, { url, loading: true }); + void navigateBrowser(id, url).catch((reason: unknown) => { + patchBrowserTab(id, { loading: false }); + setError(String(reason)); + }); + }; + + return ( +
+
+ void browserHistory(id, "back").catch(() => undefined)} + > + + + + void browserHistory(id, "forward").catch(() => undefined) + } + > + + + + void browserHistory(id, tab.loading ? "stop" : "reload").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); + }} + > + + +
+
+ {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/src/features/browser/ui/ProjectBrowserDock.tsx b/src/features/browser/ui/ProjectBrowserDock.tsx new file mode 100644 index 0000000000..4294cb9ebe --- /dev/null +++ b/src/features/browser/ui/ProjectBrowserDock.tsx @@ -0,0 +1,194 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { DockPanel } from "../../workspace/ui/DockPanel"; +import { + applyDockGridStyle, + findProjectTerminal, +} from "../../projects/model/projectTerminal"; +import { sameProjectPath } from "../../projects/model/recents"; +import { + onBrowserEvent, + retainBrowserViews, +} from "../../../platform/tauri/browser"; +import { + browserDefaultSize, + browserTabIds, + closeBrowserTab, + closeOtherBrowserTabs, + dockOfTab, + getBrowserState, + hideBrowser, + openBrowserTab, + patchBrowserTab, + reorderBrowserTabs, + selectBrowserTab, + setBrowserProject, + setBrowserSide, + setBrowserSize, + useBrowserState, + type BrowserDock, +} from "../model/browserStore"; +import { BrowserView } from "./BrowserView"; + +function ProjectBrowserDock({ + dock, + visible, + onSizePaint, + onSizeCommit, +}: { + dock: BrowserDock; + visible: boolean; + onSizePaint: (size: number) => void; + onSizeCommit: (size: number) => void; +}) { + const [resizing, setResizing] = useState(false); + return ( + openBrowserTab()} + onSelect={selectBrowserTab} + onClose={closeBrowserTab} + onCloseOthers={closeOtherBrowserTabs} + onReorder={reorderBrowserTabs} + > + {dock.pane.files.map((file) => { + const active = file.id === dock.pane.activeFileId; + return file.browser ? ( +
+ +
+ ) : null; + })} +
+ ); +} + +/** Keep this window's tabs in step with their native pages. */ +function useBrowserEvents() { + useEffect(() => { + void retainBrowserViews(browserTabIds()).catch(() => undefined); + const unlisten = onBrowserEvent((event) => { + if (event.kind === "load") { + patchBrowserTab(event.id, { url: event.url, loading: event.loading }); + } else if (event.kind === "title") { + patchBrowserTab(event.id, { title: event.title }); + } else { + const dock = dockOfTab(getBrowserState(), event.id); + openBrowserTab(event.url, { + projectPath: dock?.projectPath, + afterId: event.id, + }); + } + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, []); +} + +/** + * Lays the current project's browser dock beside `children`, the same way the + * terminal dock sits beside the workspace. Other projects' docks stay mounted + * but hidden so their pages keep running. + */ +export function BrowserDockLayout({ + projectPath, + hidden, + children, +}: { + projectPath: string; + /** Something covers the workspace (settings, inbox, a full-view Mono). */ + hidden: boolean; + children: ReactNode; +}) { + const state = useBrowserState(); + const grid = useRef(null); + const dragging = useRef(false); + useBrowserEvents(); + + useLayoutEffect(() => { + setBrowserProject(projectPath); + }, [projectPath]); + + const current = findProjectTerminal(state.docks, state.projectPath); + const showSide = current?.open && !hidden ? current.side : null; + const size = current?.size ?? 0; + + 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 ( +
+ {state.docks.map((dock) => { + const show = + dock.open && + !hidden && + sameProjectPath(dock.projectPath, state.projectPath); + return ( +
+ +
+ ); + })} +
+ {children} +
+
+ ); +} diff --git a/src/features/terminal/ui/ProjectTerminalDock.tsx b/src/features/terminal/ui/ProjectTerminalDock.tsx index 881a1b75db..5a2f354b2e 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,207 +44,43 @@ 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/workspace/model/layout.ts b/src/features/workspace/model/layout.ts index f396a4df94..24df9bf8e0 100644 --- a/src/features/workspace/model/layout.ts +++ b/src/features/workspace/model/layout.ts @@ -75,12 +75,21 @@ 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; +}; + export type EditorPane = { id: string; files: FilePaneTab[]; @@ -424,6 +433,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 +456,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..096872f079 --- /dev/null +++ b/src/features/workspace/ui/DockPanel.tsx @@ -0,0 +1,293 @@ +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 ProjectTerminalDock, +} from "../../projects/model/projectTerminal"; + +type Props = { + dock: ProjectTerminalDock; + /** 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; + 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, + 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 ? ( { + return invoke("browser_open", { id, url, bounds, visible }); +} + +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 function navigateBrowser(id: string, url: string): Promise { + return invoke("browser_navigate", { id, url }); +} + +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), + ); +} From 246470ef475a7992f624d84700c3023f15e47c01 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 21:20:30 -0400 Subject: [PATCH 02/24] Make the status bar Terminal button a toggle It now matches the Browser button: pressed and accented only while the terminal dock is on screen, and a click shows or hides it (same as Cmd+J), creating a terminal the first time. Before, it stayed accented whenever the project had a terminal and could only show the dock. Co-Authored-By: Claude Opus 5.5 --- src/app/App.tsx | 38 +++---------------------------- src/app/shell/UsageFooter.test.ts | 23 +++++++++++++++---- src/app/shell/UsageFooter.tsx | 27 +++++++++------------- 3 files changed, 33 insertions(+), 55 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index e62ec0e6db..bb39a1ccb7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2804,29 +2804,6 @@ function Workspace({ onOpenTerminal(terminalCwd); }, [terminalCwd, onOpenTerminal]); - const onShowProjectTerminal = useCallback(() => { - leaveCoveringMono(); - const dock = findProjectTerminal(projectTerminalsRef.current, projectCwd); - if (dock && dock.pane.files.length > 0) { - if (!dock.open) { - setProjectTerminals((prev) => - mapProjectTerminal(prev, projectCwd, (entry) => - withDockOpen(entry, true), - ), - ); - } - focusProjectTerminal(); - return; - } - onOpenTerminal(terminalCwd); - }, [ - terminalCwd, - focusProjectTerminal, - leaveCoveringMono, - onOpenTerminal, - projectCwd, - ]); - const onNewTerminalInSession = useCallback( (sessionId: string) => { const session = sessionsRef.current.find( @@ -12526,21 +12503,12 @@ function Workspace({ terminals={monoCovers ? undefined : runningTerminals} terminalOpen={!monoCovers && runningTerminalOpen} onToggleTerminal={onToggleRunningTerminal} - onNewTerminal={ + onToggleProjectTerminal={ !monoCovers && isLocalProject(projectCwd) - ? onNewTerminal + ? onToggleProjectTerminal : undefined } - onShowTerminal={ - !monoCovers && isLocalProject(projectCwd) - ? onShowProjectTerminal - : undefined - } - projectTerminalActive={ - !monoCovers && - !!currentProjectDock && - currentProjectDock.pane.files.length > 0 - } + projectTerminalOpen={!monoCovers && dockVisible} showBrowser={!monoCovers} /> )} diff --git a/src/app/shell/UsageFooter.test.ts b/src/app/shell/UsageFooter.test.ts index 2969d1740b..e283744ee3 100644 --- a/src/app/shell/UsageFooter.test.ts +++ b/src/app/shell/UsageFooter.test.ts @@ -18,9 +18,8 @@ describe("UsageFooter terminal control", () => { ], terminalOpen: true, onToggleTerminal: vi.fn(), - onNewTerminal: vi.fn(), - onShowTerminal: vi.fn(), - projectTerminalActive: true, + onToggleProjectTerminal: vi.fn(), + projectTerminalOpen: true, }), ); @@ -33,11 +32,27 @@ describe("UsageFooter terminal control", () => { const markup = renderToStaticMarkup( createElement(UsageFooter, { providers: [], - onNewTerminal: vi.fn(), + onToggleProjectTerminal: vi.fn(), }), ); expect(markup).toContain(">Terminal"); expect(markup.match(/
diff --git a/src/features/browser/model/browserAgent.ts b/src/features/browser/model/browserAgent.ts new file mode 100644 index 0000000000..b9a734d6be --- /dev/null +++ b/src/features/browser/model/browserAgent.ts @@ -0,0 +1,321 @@ +import { + browserHistory, + closeBrowserView, + evalInBrowser, + navigateBrowser, + screenshotBrowser, +} from "../../../platform/tauri/browser"; +import { loadAgentBrowser } from "../../settings/model/displayPrefs"; +import type { BrowserTabSource } from "../../workspace/model/layout"; +import { + closeBrowserTab, + dockOfTab, + findBrowserDock, + getBrowserState, + openBrowserTab, + patchBrowserTab, + selectBrowserTab, + subscribeBrowser, + touchAgentTab, +} from "./browserStore"; +import { + clickScript, + consoleScript, + readScript, + typeScript, + waitScript, +} from "./browserScripts"; +import { browserUrlFromInput, isBrowsableUrl } from "./browserUrl"; +import { whenNativeTabReady } from "./nativeTabs"; + +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; +} + +/** + * Resolve once a load that starts within `startWithinMs` finishes, or right + * away when none starts (a hash change, a click that did not navigate). + */ +function waitForLoad( + id: string, + { startWithinMs = 1_500, timeoutMs = LOAD_TIMEOUT_MS } = {}, +): Promise<{ timedOut?: true }> { + return new Promise((resolve) => { + let started = !!tabSource(id)?.loading; + let unsubscribe = () => {}; + const finish = (result: { timedOut?: true }) => { + unsubscribe(); + clearTimeout(startTimer); + clearTimeout(timer); + resolve(result); + }; + const check = () => { + const tab = tabSource(id); + if (!tab) return finish({}); + if (tab.loading) started = true; + else if (started) finish({}); + }; + const startTimer = setTimeout(() => { + if (!started) finish({}); + }, startWithinMs); + const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs); + unsubscribe = subscribeBrowser(check); + check(); + }); +} + +/** 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 whenNativeTabReady(id, 8_000); + 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 waitForLoad(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); + patchBrowserTab(id, { loading: true }); + await whenNativeTabReady(id, 8_000); + const load = await waitForLoad(id); + return { ...summary(id), ...load }; + }, + + async browser_navigate(input) { + const id = await useTab(input); + const url = pageUrl(input); + patchBrowserTab(id, { url, loading: true, error: undefined }); + await navigateBrowser(id, url).catch((error: unknown) => { + patchBrowserTab(id, { loading: false }); + throw error; + }); + return { ...summary(id), ...(await waitForLoad(id)) }; + }, + + 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); + await browserHistory(id, action); + return { ...summary(id), ...(await waitForLoad(id)) }; + }, + + 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..95882994b0 --- /dev/null +++ b/src/features/browser/model/browserMcp.test.ts @@ -0,0 +1,69 @@ +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..8f59f644c5 --- /dev/null +++ b/src/features/browser/model/browserMcp.ts @@ -0,0 +1,85 @@ +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"; + +/** 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/browserScripts.test.ts b/src/features/browser/model/browserScripts.test.ts new file mode 100644 index 0000000000..47e7826e8d --- /dev/null +++ b/src/features/browser/model/browserScripts.test.ts @@ -0,0 +1,112 @@ +// @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..3eadf9cc21 --- /dev/null +++ b/src/features/browser/model/browserScripts.ts @@ -0,0 +1,162 @@ +/** + * 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 index 4ac36ac362..72787837ba 100644 --- a/src/features/browser/model/browserStore.test.ts +++ b/src/features/browser/model/browserStore.test.ts @@ -1,26 +1,32 @@ import { describe, expect, it } from "vitest"; import { addBrowserTab, + AGENT_TAB_TTL_MS, + forgetBrowserSessionsIn, + liveBrowserTabIds, newBrowserTab, parseBrowserState, patchBrowserTabIn, + pruneAgentTabsIn, serializeBrowserState, toggleBrowserDock, + touchAgentTabIn, type BrowserState, } from "./browserStore"; const empty: BrowserState = { docks: [], - projectPath: "/work/app", + sessionId: "chat-a", lastSide: "right", + agentTabs: {}, }; -describe("browser docks", () => { +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({ - projectPath: "/work/app", + sessionId: "chat-a", side: "right", open: true, }); @@ -31,13 +37,32 @@ describe("browser docks", () => { 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/", "/work/app"); - const b = newBrowserTab("https://b.test/", "/work/app"); - const c = newBrowserTab("https://c.test/", "/work/app"); - let state = addBrowserTab(empty, a); - state = addBrowserTab(state, b); - state = addBrowserTab(state, c, undefined, { afterId: a.id }); + 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, @@ -46,19 +71,21 @@ describe("browser docks", () => { expect(state.docks[0].pane.activeFileId).toBe(c.id); }); - it("keeps each project's tabs in its own dock", () => { - const a = newBrowserTab("https://a.test/", "/work/app"); - const b = newBrowserTab("https://b.test/", "/work/other"); - const state = addBrowserTab(addBrowserTab(empty, a), b, "/work/other"); - expect(state.docks.map((dock) => dock.projectPath)).toEqual([ - "/work/app", - "/work/other", - ]); + 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/", "/work/app"); - const state = addBrowserTab(empty, tab); + 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", @@ -70,17 +97,53 @@ describe("browser docks", () => { 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 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/", "/work/app"); - const state = patchBrowserTabIn(addBrowserTab(empty, tab), tab.id, { - title: "A", - loading: true, - }); + 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", @@ -91,9 +154,10 @@ describe("browser persistence", () => { expect(parseBrowserState("not json").docks).toEqual([]); const raw = JSON.stringify({ docks: [ - { projectPath: "/a", side: "middle", pane: { files: [] } }, + { sessionId: "a", side: "middle", pane: { files: [] } }, + { projectPath: "/old", side: "left", pane: { files: [] } }, { - projectPath: "/b", + sessionId: "b", side: "left", size: 400, open: true, @@ -102,7 +166,12 @@ describe("browser persistence", () => { activeFileId: "gone", files: [ { id: "../bad", browser: { url: "https://x.test" } }, - { id: "ok-1", path: "https://y.test", cwd: "/b", browser: { url: "https://y.test" } }, + { + id: "ok-1", + path: "https://y.test", + cwd: "", + browser: { url: "https://y.test" }, + }, ], }, }, diff --git a/src/features/browser/model/browserStore.ts b/src/features/browser/model/browserStore.ts index dfa200eec3..3c68343263 100644 --- a/src/features/browser/model/browserStore.ts +++ b/src/features/browser/model/browserStore.ts @@ -4,38 +4,43 @@ import { addTerminalToDock, clampDockSize, closeTerminalInDock, - createProjectTerminal, - findProjectTerminal, isDockSide, - mapProjectTerminal, reorderDockTerminals, selectDockTerminal, withDockOpen, withDockSide, withDockSize, type DockSide, - type ProjectTerminalDock, + type DockState, } from "../../projects/model/projectTerminal"; -import { normalizeProjectPath } from "../../projects/model/recents"; +import { newEditorPane } from "../../workspace/model/layout"; import type { BrowserTabSource, FilePaneTab, } from "../../workspace/model/layout"; import { BLANK_URL } from "./browserUrl"; -/** Browser docks reuse the terminal dock model: per project, one pane of tabs. */ -export type BrowserDock = ProjectTerminalDock; +/** One session's browser: its tabs and where its panel sits. */ +export type BrowserDock = DockState & { sessionId: string }; export type BrowserState = { docks: BrowserDock[]; - /** Project the window is showing; new tabs land in its dock. */ - projectPath: string; + /** 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; other hidden tabs are suspended. + * Not persisted. + */ + agentTabs: 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; const DEFAULT_SIZE = { horizontal: 520, vertical: 320 }; -const STORAGE_PREFIX = "monocode.browser.v1:"; +const STORAGE_PREFIX = "monocode.browser.v2:"; export function browserDefaultSize(side: DockSide): number { return side === "left" || side === "right" @@ -43,11 +48,11 @@ export function browserDefaultSize(side: DockSide): number { : DEFAULT_SIZE.vertical; } -export function newBrowserTab(url: string, cwd: string): FilePaneTab { +export function newBrowserTab(url: string): FilePaneTab { return { id: crypto.randomUUID(), path: url, - cwd, + cwd: "", browser: { url }, }; } @@ -60,51 +65,93 @@ function viewport() { : { 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, - projectPath = state.projectPath, - options: { afterId?: string } = {}, + options: { afterId?: string; open?: boolean } = {}, ): BrowserState { - const existing = findProjectTerminal(state.docks, projectPath); + const existing = findBrowserDock(state.docks, sessionId); if (!existing) { - const dock = createProjectTerminal(projectPath, file, state.lastSide); - return { - ...state, - docks: [ - ...state.docks, - { - ...dock, - size: clampDockSize( - dock.side, - browserDefaultSize(dock.side), - viewport(), - ), - }, - ], + 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 { - ...state, - docks: mapProjectTerminal(state.docks, projectPath, (dock) => { - const next = addTerminalToDock(dock, file); - 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 } }; - }), - }; + 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 { - const dock = findProjectTerminal(state.docks, state.projectPath); - if (!dock) { - return addBrowserTab(state, newBrowserTab(BLANK_URL, state.projectPath)); + if (!state.sessionId) return state; + if (!findBrowserDock(state.docks, state.sessionId)) { + return addBrowserTab(state, state.sessionId, newBrowserTab(BLANK_URL)); } - return mapDock(state, (entry) => withDockOpen(entry, !entry.open)); + return mapDock(state, state.sessionId, (dock) => + withDockOpen(dock, !dock.open), + ); } export function patchBrowserTabIn( @@ -112,43 +159,75 @@ export function patchBrowserTabIn( fileId: string, patch: Partial, ): BrowserState { - let changed = false; - const docks = state.docks.map((dock) => { - if (!dock.pane.files.some((file) => file.id === fileId)) return dock; + 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.loading === file.browser.loading && + browser.error === file.browser.error ) { return file; } changed = true; return { ...file, path: browser.url, browser }; }); - return { ...dock, pane: { ...dock.pane, files } }; + return changed ? { ...dock, pane: { ...dock.pane, files } } : dock; }); - return changed ? { ...state, docks } : state; } -export function dockOfTab( +/** 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, -): BrowserDock | undefined { - return state.docks.find((dock) => - dock.pane.files.some((file) => file.id === fileId), - ); + now: number, +): BrowserState { + return { ...state, agentTabs: { ...state.agentTabs, [fileId]: now } }; } -function mapDock( +export function pruneAgentTabsIn( state: BrowserState, - update: (dock: BrowserDock) => BrowserDock | null, - projectPath = state.projectPath, + now: number, + ttl = AGENT_TAB_TTL_MS, ): BrowserState { - const docks = mapProjectTerminal(state.docks, projectPath, update); - return docks === state.docks ? state : { ...state, docks }; + 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, + * plus tabs an agent is using. Everything else is suspended to its URL. + */ +export function liveBrowserTabIds(state: BrowserState): Set { + const live = new Set(Object.keys(state.agentTabs)); + const current = findBrowserDock(state.docks, state.sessionId); + for (const file of current?.pane.files ?? []) live.add(file.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 ------------------------------------------------------- @@ -185,7 +264,9 @@ export function parseBrowserState(raw: string | null): StoredState { const value = JSON.parse(raw) as Partial; const docks = (Array.isArray(value.docks) ? value.docks : []).flatMap( (dock): BrowserDock[] => { - if (!dock || typeof dock.projectPath !== "string") return []; + if (!dock || typeof dock.sessionId !== "string" || !dock.sessionId) { + return []; + } if (!isDockSide(dock.side) || !dock.pane) return []; const files = (dock.pane.files ?? []).filter( (file) => @@ -201,11 +282,15 @@ export function parseBrowserState(raw: string | null): StoredState { : files[0].id; return [ { - projectPath: dock.projectPath, + 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 }, + pane: { + id: dock.pane.id || crypto.randomUUID(), + files, + activeFileId, + }, }, ]; }, @@ -221,7 +306,7 @@ export function parseBrowserState(raw: string | null): StoredState { // ---- store ------------------------------------------------------------- -// Per window: each window keeps its own docks, like its terminals. +// Per window, like terminals: each window keeps its own sessions' browsers. function storageKey(): string | null { try { return `${STORAGE_PREFIX}${getCurrentWindow().label}`; @@ -235,14 +320,16 @@ function initialState(): BrowserState { const stored = parseBrowserState(key ? localStorage.getItem(key) : null); return { docks: stored.docks, - projectPath: "", + sessionId: "", lastSide: stored.lastSide ?? BROWSER_DEFAULT_SIDE, + agentTabs: {}, }; } let state: BrowserState | null = null; const listeners = new Set<() => void>(); let saveTimer: ReturnType | undefined; +let pruneTimer: ReturnType | undefined; export function getBrowserState(): BrowserState { state ??= initialState(); @@ -250,9 +337,13 @@ export function getBrowserState(): BrowserState { } function setState(next: BrowserState) { - if (next === getBrowserState()) return; + const previous = getBrowserState(); + if (next === previous) return; state = next; for (const listener of listeners) listener(); + if (next.docks === previous.docks && next.lastSide === previous.lastSide) { + return; + } clearTimeout(saveTimer); saveTimer = setTimeout(() => { const key = storageKey(); @@ -273,10 +364,9 @@ export function useBrowserState(): BrowserState { return useSyncExternalStore(subscribeBrowser, getBrowserState); } -export function setBrowserProject(projectPath: string) { - const path = projectPath ? normalizeProjectPath(projectPath) : ""; +export function setBrowserSession(sessionId: string) { update((current) => - current.projectPath === path ? current : { ...current, projectPath: path }, + current.sessionId === sessionId ? current : { ...current, sessionId }, ); } @@ -284,64 +374,64 @@ export function toggleBrowser() { update(toggleBrowserDock); } -export function showBrowser() { +export function hideBrowser() { update((current) => - findProjectTerminal(current.docks, current.projectPath) - ? mapDock(current, (dock) => withDockOpen(dock, true)) - : toggleBrowserDock(current), + mapDock(current, current.sessionId, (dock) => withDockOpen(dock, false)), ); } -export function hideBrowser() { - update((current) => mapDock(current, (dock) => withDockOpen(dock, false))); -} - -/** Open `url` in a new tab of the current project's browser and show it. */ +/** + * 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: { projectPath?: string; afterId?: string } = {}, -): string { - const projectPath = options.projectPath ?? getBrowserState().projectPath; - const file = newBrowserTab(url, projectPath); - update((current) => - mapDock( - addBrowserTab(current, file, projectPath, options), - (dock) => withDockOpen(dock, true), - projectPath, - ), - ); + 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) => mapDock(current, (dock) => selectDockTerminal(dock, fileId))); + update((current) => + mapDockOfTab(current, fileId, (dock) => selectDockTerminal(dock, fileId)), + ); } export function closeBrowserTab(fileId: string) { - update((current) => { - const dock = dockOfTab(current, fileId); - if (!dock) return current; - return mapDock( - current, - (entry) => closeTerminalInDock(entry, fileId), - dock.projectPath, - ); - }); + update((current) => + mapDockOfTab(current, fileId, (dock) => closeTerminalInDock(dock, fileId)), + ); } export function closeOtherBrowserTabs(fileId: string) { update((current) => - mapDock(current, (dock) => { + mapDockOfTab(current, fileId, (dock) => { const keep = dock.pane.files.filter((file) => file.id === fileId); - if (keep.length === 0) return dock; - return { ...dock, pane: { ...dock.pane, files: keep, activeFileId: fileId } }; + return { + ...dock, + pane: { ...dock.pane, files: keep, activeFileId: fileId }, + }; }), ); } export function reorderBrowserTabs(ids: string[]) { update((current) => - mapDock(current, (dock) => { + 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; @@ -352,14 +442,18 @@ export function reorderBrowserTabs(ids: string[]) { export function setBrowserSide(side: DockSide) { update((current) => ({ - ...mapDock(current, (dock) => withDockSide(dock, side, viewport())), + ...mapDock(current, current.sessionId, (dock) => + withDockSide(dock, side, viewport()), + ), lastSide: side, })); } export function setBrowserSize(size: number) { update((current) => - mapDock(current, (dock) => withDockSize(dock, size, viewport())), + mapDock(current, current.sessionId, (dock) => + withDockSize(dock, size, viewport()), + ), ); } @@ -370,7 +464,18 @@ export function patchBrowserTab( update((current) => patchBrowserTabIn(current, fileId, patch)); } -/** Every native tab id this window should keep alive. */ -export function browserTabIds(current = getBrowserState()): string[] { - return current.docks.flatMap((dock) => dock.pane.files.map((file) => file.id)); +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/nativeTabs.ts b/src/features/browser/model/nativeTabs.ts new file mode 100644 index 0000000000..8ec332c91b --- /dev/null +++ b/src/features/browser/model/nativeTabs.ts @@ -0,0 +1,30 @@ +/** 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 whenNativeTabReady(id: string, timeoutMs = 5000): Promise { + if (ready.has(id)) return Promise.resolve(); + return new Promise((resolve, reject) => { + const list = waiters.get(id) ?? new Set<() => void>(); + waiters.set(id, list); + const wake = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + list.delete(wake); + reject(new Error("The browser tab did not open")); + }, timeoutMs); + list.add(wake); + }); +} diff --git a/src/features/browser/model/openLink.ts b/src/features/browser/model/openLink.ts index 59f7ee3613..3ac6bab089 100644 --- a/src/features/browser/model/openLink.ts +++ b/src/features/browser/model/openLink.ts @@ -26,8 +26,11 @@ export function linkTarget( /** Open a link the user clicked in app content (chat, terminal). */ export function openLink(url: string, modifiers?: Modifiers | null): Promise { - if (linkTarget(url, loadOpenLinksInApp(), modifiers) === "browser") { - openBrowserTab(url); + // 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 openExternalUrl(url); diff --git a/src/features/browser/ui/BrowserStatusButton.tsx b/src/features/browser/ui/BrowserStatusButton.tsx index 9526d83df3..fd272bdb7e 100644 --- a/src/features/browser/ui/BrowserStatusButton.tsx +++ b/src/features/browser/ui/BrowserStatusButton.tsx @@ -1,11 +1,16 @@ import { Globe } from "../../../shared/ui/icons"; -import { findProjectTerminal } from "../../projects/model/projectTerminal"; -import { toggleBrowser, useBrowserState } from "../model/browserStore"; +import { findBrowserDock, toggleBrowser, useBrowserState } from "../model/browserStore"; -/** Status bar toggle for the current project's browser dock. */ +/** Status bar toggle for the focused session's browser. */ export function BrowserStatusButton() { const state = useBrowserState(); - const open = !!findProjectTerminal(state.docks, state.projectPath)?.open; + 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 index 3ff136583b..501b5f2b20 100644 --- a/src/features/browser/ui/BrowserView.tsx +++ b/src/features/browser/ui/BrowserView.tsx @@ -33,19 +33,11 @@ import type { BrowserTabSource } from "../../workspace/model/layout"; import { BLANK_URL, browserUrlFromInput } from "../model/browserUrl"; import { patchBrowserTab } from "../model/browserStore"; import { useOverlayOcclusion } from "../hooks/useOverlayOcclusion"; - -type Props = { - id: string; - tab: BrowserTabSource; - /** Active tab of a dock that is on screen. */ - visible: boolean; - /** The dock sash is being dragged. */ - resizing: boolean; -}; +import { setNativeTabReady } from "../model/nativeTabs"; /** - * React StrictMode and dock moves unmount and remount a view in one turn. - * Defer the native close so a remount can take the live page back. + * 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>(); @@ -59,17 +51,31 @@ function sameBounds(a: BrowserBounds | null, b: BrowserBounds): boolean { ); } -export function BrowserView({ id, tab, visible, resizing }: Props) { +/** + * 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, + resizing = false, + className = "", +}: { + id: string; + tab: BrowserTabSource; + /** Draw the page. Off-window hosts pass true so the page keeps rendering. */ + visible: boolean; + /** The dock sash is being dragged. */ + resizing?: boolean; + className?: string; +}) { const host = useRef(null); const scale = useSyncExternalStore(subscribeUiScale, loadUiScale); const covered = useOverlayOcclusion(host, visible); const shown = visible && !covered; const [ready, setReady] = useState(false); - const [address, setAddress] = useState(tab.url === BLANK_URL ? "" : tab.url); - const [editing, setEditing] = useState(false); - const [error, setError] = useState(null); const placed = useRef(null); - const input = useRef(null); const measure = useCallback((): BrowserBounds | null => { const el = host.current; @@ -94,11 +100,19 @@ export function BrowserView({ id, tab, visible, resizing }: Props) { placed.current = bounds; let alive = true; openBrowserView(id, tab.url, bounds, false) - .then(() => alive && setReady(true)) - .catch((reason: unknown) => alive && setError(String(reason))); + .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(() => { @@ -160,101 +174,116 @@ export function BrowserView({ id, tab, visible, resizing }: Props) { 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 (ready && visible && tab.url === BLANK_URL) input.current?.focus(); + if (tab.url === BLANK_URL) input.current?.focus(); // Only when a blank tab first appears. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, visible]); + }, [id]); const go = (event: FormEvent) => { event.preventDefault(); const url = browserUrlFromInput(address); setEditing(false); - setError(null); input.current?.blur(); - patchBrowserTab(id, { url, loading: true }); + patchBrowserTab(id, { url, loading: true, error: undefined }); void navigateBrowser(id, url).catch((reason: unknown) => { - patchBrowserTab(id, { loading: false }); - setError(String(reason)); + patchBrowserTab(id, { loading: false, error: String(reason) }); }); }; return ( -
-
+ void browserHistory(id, "back").catch(() => undefined)} > - void browserHistory(id, "back").catch(() => undefined)} - > - - - - void browserHistory(id, "forward").catch(() => undefined) - } - > - - - - void browserHistory(id, tab.loading ? "stop" : "reload").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); - }} - > - - - -
- {error ? ( -
- {error} -
- ) : null} -
-
+ + + + void browserHistory(id, "forward").catch(() => undefined) + } + > + + + + void browserHistory(id, tab.loading ? "stop" : "reload").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/ProjectBrowserDock.tsx b/src/features/browser/ui/ProjectBrowserDock.tsx deleted file mode 100644 index 4294cb9ebe..0000000000 --- a/src/features/browser/ui/ProjectBrowserDock.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type ReactNode, -} from "react"; -import { DockPanel } from "../../workspace/ui/DockPanel"; -import { - applyDockGridStyle, - findProjectTerminal, -} from "../../projects/model/projectTerminal"; -import { sameProjectPath } from "../../projects/model/recents"; -import { - onBrowserEvent, - retainBrowserViews, -} from "../../../platform/tauri/browser"; -import { - browserDefaultSize, - browserTabIds, - closeBrowserTab, - closeOtherBrowserTabs, - dockOfTab, - getBrowserState, - hideBrowser, - openBrowserTab, - patchBrowserTab, - reorderBrowserTabs, - selectBrowserTab, - setBrowserProject, - setBrowserSide, - setBrowserSize, - useBrowserState, - type BrowserDock, -} from "../model/browserStore"; -import { BrowserView } from "./BrowserView"; - -function ProjectBrowserDock({ - dock, - visible, - onSizePaint, - onSizeCommit, -}: { - dock: BrowserDock; - visible: boolean; - onSizePaint: (size: number) => void; - onSizeCommit: (size: number) => void; -}) { - const [resizing, setResizing] = useState(false); - return ( - openBrowserTab()} - onSelect={selectBrowserTab} - onClose={closeBrowserTab} - onCloseOthers={closeOtherBrowserTabs} - onReorder={reorderBrowserTabs} - > - {dock.pane.files.map((file) => { - const active = file.id === dock.pane.activeFileId; - return file.browser ? ( -
- -
- ) : null; - })} -
- ); -} - -/** Keep this window's tabs in step with their native pages. */ -function useBrowserEvents() { - useEffect(() => { - void retainBrowserViews(browserTabIds()).catch(() => undefined); - const unlisten = onBrowserEvent((event) => { - if (event.kind === "load") { - patchBrowserTab(event.id, { url: event.url, loading: event.loading }); - } else if (event.kind === "title") { - patchBrowserTab(event.id, { title: event.title }); - } else { - const dock = dockOfTab(getBrowserState(), event.id); - openBrowserTab(event.url, { - projectPath: dock?.projectPath, - afterId: event.id, - }); - } - }); - return () => { - void unlisten.then((fn) => fn()); - }; - }, []); -} - -/** - * Lays the current project's browser dock beside `children`, the same way the - * terminal dock sits beside the workspace. Other projects' docks stay mounted - * but hidden so their pages keep running. - */ -export function BrowserDockLayout({ - projectPath, - hidden, - children, -}: { - projectPath: string; - /** Something covers the workspace (settings, inbox, a full-view Mono). */ - hidden: boolean; - children: ReactNode; -}) { - const state = useBrowserState(); - const grid = useRef(null); - const dragging = useRef(false); - useBrowserEvents(); - - useLayoutEffect(() => { - setBrowserProject(projectPath); - }, [projectPath]); - - const current = findProjectTerminal(state.docks, state.projectPath); - const showSide = current?.open && !hidden ? current.side : null; - const size = current?.size ?? 0; - - 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 ( -
- {state.docks.map((dock) => { - const show = - dock.open && - !hidden && - sameProjectPath(dock.projectPath, state.projectPath); - return ( -
- -
- ); - })} -
- {children} -
-
- ); -} diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx new file mode 100644 index 0000000000..dacb886203 --- /dev/null +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -0,0 +1,223 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { DockPanel } from "../../workspace/ui/DockPanel"; +import { applyDockGridStyle } from "../../projects/model/projectTerminal"; +import { + onBrowserEvent, + 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, +} from "../model/browserStore"; +import { BrowserSurface, BrowserToolbar } from "./BrowserView"; + +/** 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 ? ( +
+ + +
+ ) : 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") { + 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, + }); + } + }); + return () => { + 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 background tabs, and tabs an agent + * is using) keep running in an off-window host; the rest are suspended. + */ +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} +
+ {background.length > 0 ? ( +
+ {background.map((file) => + file.browser ? ( + + ) : null, + )} +
+ ) : null} +
+ ); +} diff --git a/src/features/projects/model/projectTerminal.ts b/src/features/projects/model/projectTerminal.ts index 545aea1858..997bb0f95f 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); @@ -128,10 +132,10 @@ export function closeTerminalInDock( return { ...dock, pane: { ...dock.pane, files, activeFileId } }; } -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 @@ -141,10 +145,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 } }; } @@ -197,18 +201,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, @@ -217,11 +221,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/settings/model/displayPrefs.ts b/src/features/settings/model/displayPrefs.ts index 33b90bca45..3970950ab7 100644 --- a/src/features/settings/model/displayPrefs.ts +++ b/src/features/settings/model/displayPrefs.ts @@ -81,3 +81,19 @@ const openLinksInApp = flagStore( 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; diff --git a/src/features/settings/model/settings.ts b/src/features/settings/model/settings.ts index d0460496fc..d47c26a026 100644 --- a/src/features/settings/model/settings.ts +++ b/src/features/settings/model/settings.ts @@ -255,6 +255,12 @@ export const SETTINGS_INDEX: SettingsEntry[] = [ 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 1221cf4d39..ad4f95567d 100644 --- a/src/features/settings/ui/SettingsView.tsx +++ b/src/features/settings/ui/SettingsView.tsx @@ -232,9 +232,11 @@ import { } from "../../providers/model/providerAccountIdentity"; import { ProviderAccountSubtitle } from "../../providers/ui/ProviderAccountSubtitle"; import { + saveAgentBrowser, saveMaskEmails, saveOpenLinksInApp, saveShowRemainingUsage, + useAgentBrowser, useMaskEmails, useOpenLinksInApp, useShowRemainingUsage, @@ -888,6 +890,7 @@ function GeneralPage({ /> + + + + ); +} + function UsageDisplaySettings() { const showRemainingUsage = useShowRemainingUsage(); const maskEmails = useMaskEmails(); diff --git a/src/features/workspace/model/layout.ts b/src/features/workspace/model/layout.ts index 24df9bf8e0..63541f9620 100644 --- a/src/features/workspace/model/layout.ts +++ b/src/features/workspace/model/layout.ts @@ -88,6 +88,8 @@ export type BrowserTabSource = { title?: string; /** Live only — not persisted. */ loading?: boolean; + /** Why the page could not open or load. Live only. */ + error?: string; }; export type EditorPane = { diff --git a/src/features/workspace/ui/DockPanel.tsx b/src/features/workspace/ui/DockPanel.tsx index 096872f079..8e622bf6d7 100644 --- a/src/features/workspace/ui/DockPanel.tsx +++ b/src/features/workspace/ui/DockPanel.tsx @@ -23,11 +23,11 @@ import { clampDockSize, isVerticalDock, type DockSide, - type ProjectTerminalDock, + type DockState, } from "../../projects/model/projectTerminal"; type Props = { - dock: ProjectTerminalDock; + dock: DockState; /** Singular surface name: "Terminal", "Browser". */ name: string; /** Accessible name of the tab strip. */ diff --git a/src/integrations/harness/providers/antigravity/antigravity.ts b/src/integrations/harness/providers/antigravity/antigravity.ts index 71ec80e673..df67a78c9b 100644 --- a/src/integrations/harness/providers/antigravity/antigravity.ts +++ b/src/integrations/harness/providers/antigravity/antigravity.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"; @@ -493,7 +494,7 @@ async function startLive(input: SendTurnInput, life: number): Promise { try { setup = await acp.request( "session/resume", - { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers: [] }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers: await acpBrowserMcpServersFor(input.sessionId) }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; @@ -512,7 +513,7 @@ async function startLive(input: SendTurnInput, life: number): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers: await acpBrowserMcpServersFor(input.sessionId), }, SESSION_TIMEOUT_MS, ); @@ -533,7 +534,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: await acpBrowserMcpServersFor(input.sessionId) }, 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 de1a23c804..1015759f94 100644 --- a/src/integrations/harness/providers/codex/codex.ts +++ b/src/integrations/harness/providers/codex/codex.ts @@ -1,4 +1,8 @@ import { TurnNotReadyError } from "../../core/types"; +import { + browserMcpFor, + codexBrowserMcpArgs, +} from "../../../../features/browser/model/browserMcp"; import { nativeModelId } from "../../../../features/sessions/model/models"; import { sameProviderAccountId } from "../../../../features/providers/model/providerAccounts"; import { @@ -585,10 +589,11 @@ async function ensureLive(input: HarnessSessionInput): Promise { }, ); + const browserMcp = await browserMcpFor(input.sessionId); await spawnChild( input.sessionId, path, - ["app-server"], + ["app-server", ...(browserMcp ? codexBrowserMcpArgs(browserMcp) : [])], input.cwd, { provider: "codex", 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..767c1097db 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"; @@ -305,7 +306,7 @@ async function ensureLive(input: SendTurnInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers: await acpBrowserMcpServersFor(input.sessionId), }, SESSION_TIMEOUT_MS, ); @@ -324,7 +325,7 @@ async function ensureLive(input: SendTurnInput): Promise { if (!acpSessionId) { setup = await acp.request( "session/new", - { cwd: input.cwd, mcpServers: [] }, + { cwd: input.cwd, mcpServers: await acpBrowserMcpServersFor(input.sessionId) }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup); diff --git a/src/integrations/harness/providers/grok/grok.ts b/src/integrations/harness/providers/grok/grok.ts index 67788ad910..21e57fea94 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"; @@ -352,7 +353,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: [], + mcpServers: await acpBrowserMcpServersFor(input.sessionId), }, SESSION_TIMEOUT_MS, ); @@ -372,7 +373,11 @@ async function ensureLive(input: HarnessSessionInput): Promise { try { setup = await acp.request( "session/new", - grokSessionNewParams(input.cwd, input.runtimeMode), + grokSessionNewParams( + input.cwd, + input.runtimeMode, + await acpBrowserMcpServersFor(input.sessionId), + ), SESSION_TIMEOUT_MS, ); } catch (error) { 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.ts b/src/platform/tauri/browser.ts index 90b08de4da..fe842f15dd 100644 --- a/src/platform/tauri/browser.ts +++ b/src/platform/tauri/browser.ts @@ -64,3 +64,30 @@ export function onBrowserEvent( 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 }); +} From 660048da34edb424e96da479dbd3a78c5771b1ea Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 22:02:54 -0400 Subject: [PATCH 05/24] Keep browser pages running across session switches Switching sessions suspended the previous session's tabs, so coming back reloaded them and lost in-page state (SPA routes, forms, sign-in flows). Out-of-view pages now stay live, most recently viewed first, up to 12 pages; past that the least recently viewed tabs of other sessions unload and reload from their URL when shown. The focused session's tabs and agent tabs always stay live. Co-Authored-By: Claude Opus 5.5 --- .../browser/model/browserStore.test.ts | 42 +++++++++++++ src/features/browser/model/browserStore.ts | 60 +++++++++++++++---- .../browser/ui/SessionBrowserDock.tsx | 6 +- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/features/browser/model/browserStore.test.ts b/src/features/browser/model/browserStore.test.ts index 72787837ba..d3f44d5b16 100644 --- a/src/features/browser/model/browserStore.test.ts +++ b/src/features/browser/model/browserStore.test.ts @@ -4,11 +4,13 @@ import { AGENT_TAB_TTL_MS, forgetBrowserSessionsIn, liveBrowserTabIds, + MAX_LIVE_TABS, newBrowserTab, parseBrowserState, patchBrowserTabIn, pruneAgentTabsIn, serializeBrowserState, + switchBrowserSessionIn, toggleBrowserDock, touchAgentTabIn, type BrowserState, @@ -19,6 +21,7 @@ const empty: BrowserState = { sessionId: "chat-a", lastSide: "right", agentTabs: {}, + recent: {}, }; describe("session browsers", () => { @@ -108,6 +111,45 @@ describe("session browsers", () => { }); 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/"); diff --git a/src/features/browser/model/browserStore.ts b/src/features/browser/model/browserStore.ts index 3c68343263..39149927c0 100644 --- a/src/features/browser/model/browserStore.ts +++ b/src/features/browser/model/browserStore.ts @@ -30,15 +30,25 @@ export type BrowserState = { lastSide: DockSide; /** * Tabs an agent used recently, by last use. They stay live while out of - * view so the agent can keep working; other hidden tabs are suspended. - * Not persisted. + * 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:"; @@ -197,7 +207,24 @@ export function touchAgentTabIn( fileId: string, now: number, ): BrowserState { - return { ...state, agentTabs: { ...state.agentTabs, [fileId]: now } }; + 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( @@ -215,13 +242,27 @@ export function pruneAgentTabsIn( } /** - * Tabs whose native page should exist: every tab of the session on screen, - * plus tabs an agent is using. Everything else is suspended to its URL. + * 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): Set { - const live = new Set(Object.keys(state.agentTabs)); +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; } @@ -323,6 +364,7 @@ function initialState(): BrowserState { sessionId: "", lastSide: stored.lastSide ?? BROWSER_DEFAULT_SIDE, agentTabs: {}, + recent: {}, }; } @@ -365,9 +407,7 @@ export function useBrowserState(): BrowserState { } export function setBrowserSession(sessionId: string) { - update((current) => - current.sessionId === sessionId ? current : { ...current, sessionId }, - ); + update((current) => switchBrowserSessionIn(current, sessionId, Date.now())); } export function toggleBrowser() { diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx index dacb886203..acbcf847d6 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -120,8 +120,10 @@ function useBrowserEvents() { /** * 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 background tabs, and tabs an agent - * is using) keep running in an off-window host; the rest are suspended. + * 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, From 4823968208495d8b21b8628d37f43b3f772c3b31 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 22:37:46 -0400 Subject: [PATCH 06/24] Route Codex browsing through MonoCode's browser tools Send browser context on every Codex turn (including resumed chats), refresh the connection when browser access changes, and require the browser MCP server. Fix duplicate React keys that stacked navigation bars in the browser dock. Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/browser_mcp.rs | 23 +++-- src/features/browser/model/browserMcp.test.ts | 12 ++- src/features/browser/model/browserMcp.ts | 12 ++- .../browser/ui/SessionBrowserDock.test.ts | 99 +++++++++++++++++++ .../browser/ui/SessionBrowserDock.tsx | 9 +- .../harness/providers/codex/codex.ts | 24 ++++- .../harness/providers/codex/codexLive.test.ts | 47 ++++++++- 7 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 src/features/browser/ui/SessionBrowserDock.test.ts diff --git a/src-tauri/src/browser_mcp.rs b/src-tauri/src/browser_mcp.rs index 50bfbf6d4c..9806dd24a8 100644 --- a/src-tauri/src/browser_mcp.rs +++ b/src-tauri/src/browser_mcp.rs @@ -11,11 +11,18 @@ 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 = "Drive this chat's browser in MonoCode. Its tabs belong to this chat only and \ -run in the background: the user can open the browser 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_console for logs and errors, browser_eval for anything else."; +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."}) @@ -30,7 +37,7 @@ fn tools() -> Value { }, { "name": "browser_open", - "description": "Open a URL in a new tab of this chat's browser and wait for the page to load. It does not open the panel. Returns the new tab id.", + "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": { @@ -93,7 +100,7 @@ fn tools() -> Value { }, { "name": "browser_eval", - "description": "Run JavaScript in the page 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.", + "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": { @@ -158,7 +165,7 @@ fn tools() -> Value { }, { "name": "browser_screenshot", - "description": "Capture a tab's viewport as a PNG image, whether or not the browser panel is open.", + "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()}} } ]) diff --git a/src/features/browser/model/browserMcp.test.ts b/src/features/browser/model/browserMcp.test.ts index 95882994b0..5a41a3bcb8 100644 --- a/src/features/browser/model/browserMcp.test.ts +++ b/src/features/browser/model/browserMcp.test.ts @@ -9,7 +9,10 @@ import { buildClaudeSpawnArgs } from "../../../integrations/harness/providers/cl 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" }, + env: { + MONOCODE_APP_ENDPOINT: "127.0.0.1:5000", + MONOCODE_APP_TOKEN: "secret", + }, }; describe("browser MCP launch", () => { @@ -32,7 +35,10 @@ describe("browser MCP 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 }); + const isolated = buildClaudeSpawnArgs({ + isolated: true, + mcpConfig: config, + }); expect(isolated.filter((arg) => arg === config)).toEqual([]); }); @@ -48,6 +54,8 @@ describe("browser MCP launch", () => { 'mcp_servers.monocode_browser.args=["browser-mcp"]', "-c", 'mcp_servers.monocode_browser.env_vars=["MONOCODE_APP_ENDPOINT","MONOCODE_APP_TOKEN"]', + "-c", + "mcp_servers.monocode_browser.required=true", ]); expect(args.join(" ")).not.toContain("secret"); }); diff --git a/src/features/browser/model/browserMcp.ts b/src/features/browser/model/browserMcp.ts index 8f59f644c5..15cd911c8a 100644 --- a/src/features/browser/model/browserMcp.ts +++ b/src/features/browser/model/browserMcp.ts @@ -7,6 +7,12 @@ 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"]; @@ -56,13 +62,13 @@ export function codexBrowserMcpArgs(launch: BrowserMcpLaunch): string[] { `${key}.args=${JSON.stringify(launch.args)}`, "-c", `${key}.env_vars=${JSON.stringify(CREDENTIAL_ENV)}`, + "-c", + `${key}.required=true`, ]; } /** ACP `mcpServers` for session/new and session/load. */ -export function acpBrowserMcpServers( - launch: BrowserMcpLaunch | null, -): Array<{ +export function acpBrowserMcpServers(launch: BrowserMcpLaunch | null): Array<{ name: string; command: string; args: string[]; diff --git a/src/features/browser/ui/SessionBrowserDock.test.ts b/src/features/browser/ui/SessionBrowserDock.test.ts new file mode 100644 index 0000000000..ce1ed43539 --- /dev/null +++ b/src/features/browser/ui/SessionBrowserDock.test.ts @@ -0,0 +1,99 @@ +// @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 { BrowserDockLayout } from "./SessionBrowserDock"; +import { + closeBrowserTab, + forgetBrowserSessions, + hideBrowser, + 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/"); + }); +}); diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx index acbcf847d6..3e026f3ce5 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -71,10 +71,13 @@ function SessionBrowserDock({ onReorder={reorderBrowserTabs} > {active?.browser ? ( -
- + // One key on the wrapper: switching tabs swaps toolbar and page together. +
+ void; @@ -277,7 +279,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 ( @@ -462,6 +468,8 @@ export function bindCodexSession( } 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; if ( @@ -471,7 +479,8 @@ async function ensureLive(input: HarnessSessionInput): Promise { existing.providerAccountId, input.providerAccountId, ) && - existing.controlsAgents === controlsAgents + existing.controlsAgents === controlsAgents && + existing.browserAvailable === browserAvailable ) { existing.onEvent = input.onEvent; return existing; @@ -479,7 +488,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( @@ -589,7 +599,6 @@ async function ensureLive(input: HarnessSessionInput): Promise { }, ); - const browserMcp = await browserMcpFor(input.sessionId); await spawnChild( input.sessionId, path, @@ -672,6 +681,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { cwd: input.cwd, providerAccountId: input.providerAccountId, controlsAgents, + browserAvailable, runtimeMode: input.runtimeMode, planning: input.intent === "plan", onEvent: input.onEvent, @@ -727,7 +737,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 8fd2934036..24eaf3e14a 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) => { @@ -100,6 +119,7 @@ async function startTurn( beforeThreadReply?: () => Promise; onAccepted?: () => void; controlsAgents?: boolean; + text?: string; } = {}, ) { const events: HarnessEvent[] = []; @@ -120,7 +140,7 @@ async function startTurn( runtimeMode: options.runtimeMode ?? "supervised", controlsAgents: options.controlsAgents, intent: options.intent, - text: "summarize the changelog", + text: options.text ?? "summarize the changelog", attachments: [], onAccepted: options.onAccepted, onEvent: (event) => events.push(event), @@ -155,6 +175,7 @@ async function startTurn( describe("codex live turn sequence", () => { beforeEach(() => { + browserMcpFor.mockResolvedValue(null); sent.length = 0; onLine = undefined; writeChild.mockClear(); @@ -169,6 +190,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 }); From 617ef244dc29c5099c583a569dba57dbf5da9e55 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 23:36:33 -0400 Subject: [PATCH 07/24] Fix browser navigation restore ordering Retain restoredBy after a rejected navigation restores the original URL so accepting the earlier requests in either order ends on the latest display URL. Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/browser.rs | 305 +++++++++- .../browser/model/browserAgent.test.ts | 527 ++++++++++++++++++ src/features/browser/model/browserAgent.ts | 133 ++++- .../browser/model/browserNavigation.test.ts | 237 ++++++++ .../browser/model/browserNavigation.ts | 96 ++++ .../browser/model/browserScripts.test.ts | 44 +- src/features/browser/model/browserScripts.ts | 10 +- .../browser/model/browserStore.test.ts | 48 +- src/features/browser/model/browserStore.ts | 56 +- src/features/browser/model/browserUrl.ts | 3 +- src/features/browser/model/nativeTabs.ts | 26 +- src/features/browser/model/openLink.ts | 5 +- src/features/browser/ui/BrowserView.tsx | 37 +- .../browser/ui/SessionBrowserDock.test.ts | 61 ++ .../browser/ui/SessionBrowserDock.tsx | 6 +- 15 files changed, 1504 insertions(+), 90 deletions(-) create mode 100644 src/features/browser/model/browserAgent.test.ts create mode 100644 src/features/browser/model/browserNavigation.test.ts create mode 100644 src/features/browser/model/browserNavigation.ts diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index d8a9b0ae25..99f8f1e752 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -69,17 +69,73 @@ fn parse_url(value: &str) -> Result { } } -/// In dev the app itself is served from localhost. A tab pointed there would -/// load the app with the app's own IPC permissions, so refuse that origin. +/// 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 { + let config = app.config(); + let mut own: Vec = config.build.dev_url.iter().cloned().collect(); + 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 { - if !cfg!(debug_assertions) { - return false; + 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}")) + }) + }), } - app.config() - .build - .dev_url - .as_ref() - .is_some_and(|dev| dev.origin() == url.origin()) +} + +/// Every navigation, including redirects and link clicks, passes through here. +fn allow_navigation(app: &AppHandle, url: &Url) -> bool { + parse_url(url.as_str()).is_ok() && !is_app_origin(app, url) +} + +/// 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 { @@ -166,7 +222,7 @@ pub fn browser_open( }; let on_navigation = { let app = app.clone(); - move |url: &Url| !is_app_origin(&app, url) + move |url: &Url| allow_navigation(&app, url) }; let builder = WebviewBuilder::new(&label, WebviewUrl::External(url)) @@ -174,6 +230,7 @@ pub fn browser_open( .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( @@ -263,13 +320,20 @@ fn eval_string(webview: &Webview, js: String, timeout: Duration) -> Result(&raw).unwrap_or_default()) } -/// Wrap an agent script as the body of an async function. A script with no -/// `return` is treated as one expression so `document.title` just works. -fn agent_script(key: &str, script: &str) -> String { - let body = if script.contains("return") { - script.to_string() +/// 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 { - format!("return (\n{script}\n);") + script.to_string() }; let key = serde_json::to_string(key).unwrap_or_default(); format!( @@ -323,7 +387,17 @@ fn result_poll(key: &str) -> String { fn run_agent_script(webview: &Webview, script: &str, timeout: Duration) -> Result { let key = uuid::Uuid::new_v4().simple().to_string(); - let started = eval_string(webview, agent_script(&key, script), Duration::from_secs(5))?; + 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()); } @@ -454,16 +528,203 @@ mod tests { } #[test] - fn agent_scripts_are_async_bodies_and_expressions_return() { - let wrapped = agent_script("k1", "document.title"); - assert!(wrapped.contains("return (\ndocument.title\n);")); - assert!(wrapped.contains("var key = \"k1\";")); - let body = agent_script("k2", "const a = 1;\nreturn a;"); + 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 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 redirect_targets_are_vetted_like_typed_urls() { + // `allow_navigation` applies `parse_url` to every redirect target. + for blocked in [ + "file:///etc/passwd", + "javascript:1", + "data:text/html,x", + "blob:https://a/b", + ] { + assert!(parse_url(blocked).is_err(), "{blocked}"); + } + } + #[test] fn only_web_urls_open() { assert!(parse_url("https://example.com").is_ok()); diff --git a/src/features/browser/model/browserAgent.test.ts b/src/features/browser/model/browserAgent.test.ts new file mode 100644 index 0000000000..3e163e7dc5 --- /dev/null +++ b/src/features/browser/model/browserAgent.test.ts @@ -0,0 +1,527 @@ +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("navigates without load events and leaves the tab usable", async () => { + const id = openTab(); + setNativeTabReady(id, true); + 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", + ); + await evalCall(id); + expect(evals).toEqual([id]); + }); + + 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("reloads without load events and leaves the tab usable", async () => { + const id = openTab(); + setNativeTabReady(id, true); + const call = track( + createBrowserAgent("s1")("browser_history", { + tabId: id, + action: "reload", + }), + ); + await vi.advanceTimersByTimeAsync(1_600); + expect(call.done).toBe(true); + expect(call.error).toBeUndefined(); + expect(browserHistory).toHaveBeenCalledWith(id, "reload"); + expect(tab(id)?.loading).toBeFalsy(); + await evalCall(id); + expect(evals).toEqual([id]); + }); + + 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 index b9a734d6be..583cd90081 100644 --- a/src/features/browser/model/browserAgent.ts +++ b/src/features/browser/model/browserAgent.ts @@ -2,7 +2,6 @@ import { browserHistory, closeBrowserView, evalInBrowser, - navigateBrowser, screenshotBrowser, } from "../../../platform/tauri/browser"; import { loadAgentBrowser } from "../../settings/model/displayPrefs"; @@ -10,6 +9,8 @@ import type { BrowserTabSource } from "../../workspace/model/layout"; import { closeBrowserTab, dockOfTab, + loadGeneration, + settleLoad, findBrowserDock, getBrowserState, openBrowserTab, @@ -26,7 +27,8 @@ import { waitScript, } from "./browserScripts"; import { browserUrlFromInput, isBrowsableUrl } from "./browserUrl"; -import { whenNativeTabReady } from "./nativeTabs"; +import { isNativeTabReady, whenNativeTabReady } from "./nativeTabs"; +import { navigateBrowserTab } from "./browserNavigation"; type Input = Record; @@ -81,26 +83,31 @@ function pageUrl(input: Input): string { return url; } +type LoadResult = { timedOut?: true }; + /** * Resolve once a load that starts within `startWithinMs` finishes, or right - * away when none starts (a hash change, a click that did not navigate). + * away when none starts (a hash change, a click that did not navigate, a + * reused view that already finished). 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 } = {}, -): Promise<{ timedOut?: true }> { - return new Promise((resolve) => { +): Promise { + return new Promise((resolve, reject) => { let started = !!tabSource(id)?.loading; let unsubscribe = () => {}; - const finish = (result: { timedOut?: true }) => { + const finish = (result: LoadResult, closed = false) => { unsubscribe(); clearTimeout(startTimer); clearTimeout(timer); - resolve(result); + if (closed) reject(new Error("The browser tab was closed")); + else resolve(result); }; const check = () => { const tab = tabSource(id); - if (!tab) return finish({}); + if (!tab) return finish({}, true); if (tab.loading) started = true; else if (started) finish({}); }; @@ -113,6 +120,97 @@ function waitForLoad( }); } +/** + * 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, +): Promise { + if (!load.timedOut) return load; + const generation = loadGeneration(id); + 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?: Parameters[1], +): Promise { + return reconcileLoad(id, await waitForLoad(id, options)); +} + +/** 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); @@ -130,7 +228,7 @@ export function createBrowserAgent(sessionId: string) { ); } touchAgentTab(id); - await whenNativeTabReady(id, 8_000); + await ensureLive(id); return id; }; @@ -146,7 +244,7 @@ export function createBrowserAgent(sessionId: string) { if (!String(error).includes("navigated away")) throw error; result = { navigated: true }; } - const load = await waitForLoad(id); + const load = await waitForPage(id); return { ...(result as object), page: summary(id), ...load }; }; @@ -170,21 +268,16 @@ export function createBrowserAgent(sessionId: string) { const id = openBrowserTab(url, { sessionId, background: true }); if (!id) throw new Error("This session cannot open a browser tab"); touchAgentTab(id); - patchBrowserTab(id, { loading: true }); - await whenNativeTabReady(id, 8_000); - const load = await waitForLoad(id); + await waitForNative(id); + const load = await waitForPage(id, { startWithinMs: 3_000 }); return { ...summary(id), ...load }; }, async browser_navigate(input) { const id = await useTab(input); const url = pageUrl(input); - patchBrowserTab(id, { url, loading: true, error: undefined }); - await navigateBrowser(id, url).catch((error: unknown) => { - patchBrowserTab(id, { loading: false }); - throw error; - }); - return { ...summary(id), ...(await waitForLoad(id)) }; + await navigateBrowserTab(id, url); + return { ...summary(id), ...(await waitForPage(id)) }; }, async browser_history(input) { @@ -194,7 +287,7 @@ export function createBrowserAgent(sessionId: string) { } const id = await useTab(input); await browserHistory(id, action); - return { ...summary(id), ...(await waitForLoad(id)) }; + return { ...summary(id), ...(await waitForPage(id)) }; }, async browser_select(input) { diff --git a/src/features/browser/model/browserNavigation.test.ts b/src/features/browser/model/browserNavigation.test.ts new file mode 100644 index 0000000000..81b449542b --- /dev/null +++ b/src/features/browser/model/browserNavigation.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const pending: Array<{ + resolve: () => void; + reject: (reason: unknown) => void; +}> = []; +vi.mock("../../../platform/tauri/browser", () => ({ + navigateBrowser: vi.fn( + () => + new Promise((resolve, reject) => pending.push({ resolve, reject })), + ), +})); + +import { + dockOfTab, + getBrowserState, + closeBrowserTab, + noteLoadFinished, + noteLoadStarted, + openBrowserTab, + patchBrowserTab, +} from "./browserStore"; +import { navigateBrowserTab } from "./browserNavigation"; + +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; + }); +}); diff --git a/src/features/browser/model/browserNavigation.ts b/src/features/browser/model/browserNavigation.ts new file mode 100644 index 0000000000..dd4ed64c84 --- /dev/null +++ b/src/features/browser/model/browserNavigation.ts @@ -0,0 +1,96 @@ +import { navigateBrowser } from "../../../platform/tauri/browser"; +import { + dockOfTab, + getBrowserState, + loadGeneration, + nativeUrlEpoch, + patchBrowserTab, +} 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(); + +/** Native load events own loading: hash changes and rejected loads may emit none. */ +export async function navigateBrowserTab( + id: string, + url: string, +): Promise { + const browser = () => + dockOfTab(getBrowserState(), id)?.pane.files.find((file) => file.id === id) + ?.browser; + 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; + state.count += 1; + const request = ++state.latest; + const generation = loadGeneration(id); + const epoch = nativeUrlEpoch(id); + patchBrowserTab(id, { url, error: undefined }); + try { + await navigateBrowser(id, url); + // Accepted, so a later rejection must not roll back past it. + if (state.epoch === epoch && request > state.accepted) { + state.accepted = request; + state.url = url; + // 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 }); + } + } + } catch (reason) { + const current = + state.latest === request && + nativeUrlEpoch(id) === epoch && + !!dockOfTab(getBrowserState(), id); + 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 index 47e7826e8d..e6a9a5e740 100644 --- a/src/features/browser/model/browserScripts.test.ts +++ b/src/features/browser/model/browserScripts.test.ts @@ -29,11 +29,15 @@ 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 { + 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 { + const short = (await run( + readScript({ selector: "main", maxChars: 3 }), + )) as { content: string; truncated: boolean; }; @@ -70,33 +74,43 @@ describe("page scripts", () => { 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", + 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!
"); + document.body.insertAdjacentHTML( + "beforeend", + "
Done!
", + ); }, 50); - await expect(run(waitScript({ selector: ".late", timeoutMs: 2000 }))).resolves.toMatchObject({ + await expect( + run(waitScript({ selector: ".late", timeoutMs: 2000 })), + ).resolves.toMatchObject({ found: true, }); - await expect(run(waitScript({ text: "Done!", timeoutMs: 500 }))).resolves.toMatchObject({ + await expect( + run(waitScript({ text: "Done!", timeoutMs: 500 })), + ).resolves.toMatchObject({ found: true, }); - await expect(run(waitScript({ text: "never", timeoutMs: 150 }))).rejects.toThrow( - "Timed out", - ); + 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" }, - ]; + (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, diff --git a/src/features/browser/model/browserScripts.ts b/src/features/browser/model/browserScripts.ts index 3eadf9cc21..ff0e749603 100644 --- a/src/features/browser/model/browserScripts.ts +++ b/src/features/browser/model/browserScripts.ts @@ -40,7 +40,10 @@ export function readScript(input: { };`; } -export function consoleScript(input: { limit?: number; clear?: boolean }): string { +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." }; @@ -50,7 +53,10 @@ export function consoleScript(input: { limit?: number; clear?: boolean }): strin return { entries: recent, total: total };`; } -export function clickScript(input: { selector?: string; text?: string }): string { +export function clickScript(input: { + selector?: string; + text?: string; +}): string { return `${DESCRIBE} var selector = ${json(input.selector)}; var text = ${json(input.text)}; diff --git a/src/features/browser/model/browserStore.test.ts b/src/features/browser/model/browserStore.test.ts index d3f44d5b16..969fc88bfc 100644 --- a/src/features/browser/model/browserStore.test.ts +++ b/src/features/browser/model/browserStore.test.ts @@ -1,9 +1,16 @@ 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, @@ -76,9 +83,7 @@ describe("session browsers", () => { it("adds background tabs without opening the panel", () => { const a = newBrowserTab("https://a.test/"); - const hidden = toggleBrowserDock( - addBrowserTab(empty, "chat-a", a), - ); + 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, @@ -167,9 +172,9 @@ describe("live and suspended tabs", () => { 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({}); + expect(pruneAgentTabsIn(state, 1_000 + AGENT_TAB_TTL_MS).agentTabs).toEqual( + {}, + ); const closed = forgetBrowserSessionsIn(state, ["chat-b"]); expect(pruneAgentTabsIn(closed, 2_000).agentTabs).toEqual({}); }); @@ -225,3 +230,34 @@ describe("browser persistence", () => { 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 index 39149927c0..2f80070d5f 100644 --- a/src/features/browser/model/browserStore.ts +++ b/src/features/browser/model/browserStore.ts @@ -234,9 +234,7 @@ export function pruneAgentTabsIn( ): 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, - ); + 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) }; } @@ -382,6 +380,7 @@ 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; @@ -497,6 +496,57 @@ export function setBrowserSize(size: number) { ); } +/** 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) 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); + } +} + +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; +} + +/** Record a native load finish: its URL (e.g. a redirect) is authoritative. */ +export function noteLoadFinished(fileId: string) { + if (!dockOfTab(getBrowserState(), fileId)) return; + nativeUrlEpochs.set(fileId, nativeUrlEpoch(fileId) + 1); +} + +/** 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, diff --git a/src/features/browser/model/browserUrl.ts b/src/features/browser/model/browserUrl.ts index 00eae3ba03..2a25ceae0b 100644 --- a/src/features/browser/model/browserUrl.ts +++ b/src/features/browser/model/browserUrl.ts @@ -1,4 +1,5 @@ -const LOCAL_HOST = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?(\/|$)/i; +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+(\/|$)/; diff --git a/src/features/browser/model/nativeTabs.ts b/src/features/browser/model/nativeTabs.ts index 8ec332c91b..52d190772a 100644 --- a/src/features/browser/model/nativeTabs.ts +++ b/src/features/browser/model/nativeTabs.ts @@ -12,19 +12,39 @@ export function setNativeTabReady(id: string, isReady: boolean) { waiters.delete(id); } -export function whenNativeTabReady(id: string, timeoutMs = 5000): Promise { +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 wake = () => { + 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(() => { - list.delete(wake); + 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/openLink.ts b/src/features/browser/model/openLink.ts index 3ac6bab089..0465e4d8e1 100644 --- a/src/features/browser/model/openLink.ts +++ b/src/features/browser/model/openLink.ts @@ -25,7 +25,10 @@ export function linkTarget( } /** Open a link the user clicked in app content (chat, terminal). */ -export function openLink(url: string, modifiers?: Modifiers | null): Promise { +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" && diff --git a/src/features/browser/ui/BrowserView.tsx b/src/features/browser/ui/BrowserView.tsx index 501b5f2b20..81229a24d7 100644 --- a/src/features/browser/ui/BrowserView.tsx +++ b/src/features/browser/ui/BrowserView.tsx @@ -19,21 +19,22 @@ import { IconButton } from "../../../app/shell/TitleBar"; import { browserHistory, closeBrowserView, - navigateBrowser, openBrowserView, setBrowserBounds, setBrowserVisible, type BrowserBounds, } from "../../../platform/tauri/browser"; -import { - loadUiScale, - subscribeUiScale, -} from "../../settings/model/uiScale"; +import { loadUiScale, subscribeUiScale } from "../../settings/model/uiScale"; import type { BrowserTabSource } from "../../workspace/model/layout"; import { BLANK_URL, browserUrlFromInput } from "../model/browserUrl"; -import { patchBrowserTab } from "../model/browserStore"; +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 @@ -99,6 +100,7 @@ export function BrowserSurface({ 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; @@ -215,10 +217,7 @@ export function BrowserToolbar({ const url = browserUrlFromInput(address); setEditing(false); input.current?.blur(); - patchBrowserTab(id, { url, loading: true, error: undefined }); - void navigateBrowser(id, url).catch((reason: unknown) => { - patchBrowserTab(id, { loading: false, error: String(reason) }); - }); + void navigateBrowserTab(id, url).catch(() => undefined); }; return ( @@ -242,11 +241,16 @@ export function BrowserToolbar({ - void browserHistory(id, tab.loading ? "stop" : "reload").catch( - () => undefined, - ) - } + onClick={() => { + // 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 ? ( @@ -279,7 +283,8 @@ export function BrowserToolbar({ { - if (tab.url !== BLANK_URL) void openUrl(tab.url).catch(() => undefined); + 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 index ce1ed43539..db945b1a1d 100644 --- a/src/features/browser/ui/SessionBrowserDock.test.ts +++ b/src/features/browser/ui/SessionBrowserDock.test.ts @@ -20,11 +20,15 @@ vi.mock("@tauri-apps/api/window", () => ({ }, })); +import { browserHistory } from "../../../platform/tauri/browser"; import { BrowserDockLayout } from "./SessionBrowserDock"; import { closeBrowserTab, forgetBrowserSessions, + dockOfTab, + getBrowserState, hideBrowser, + noteLoadStarted, openBrowserTab, patchBrowserTab, selectBrowserTab, @@ -96,4 +100,61 @@ describe("BrowserDockLayout", () => { await act(async () => toggleBrowser()); expectAddress("https://b.test/"); }); + + 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 index 3e026f3ce5..9bc64912e5 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -31,6 +31,8 @@ import { setBrowserSize, useBrowserState, type BrowserDock, + noteLoadFinished, + noteLoadStarted, } from "../model/browserStore"; import { BrowserSurface, BrowserToolbar } from "./BrowserView"; @@ -97,6 +99,8 @@ function useBrowserEvents() { ); 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, @@ -183,7 +187,7 @@ export function BrowserDockLayout({ {show ? (
Date: Tue, 6 Oct 2026 23:49:26 -0400 Subject: [PATCH 08/24] Drop late agent script results from abandoned browser runs Only publish a result while the run still owns its key, and delete the entry when the caller stops waiting. Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/browser.rs | 41 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 99f8f1e752..4011b4a5c9 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -356,15 +356,21 @@ fn agent_script(key: &str, script: &str, expression: bool) -> String { return v; }}); }} - store[key] = {{ pending: true }}; + 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 {{ store[key] = {{ ok: true, json: encode(value) }}; }} - catch (e) {{ store[key] = {{ ok: false, error: "Result is not serializable: " + e }}; }} + try {{ settle({{ ok: true, json: encode(value) }}); }} + catch (e) {{ settle({{ ok: false, error: "Result is not serializable: " + e }}); }} }}, - function (e) {{ store[key] = {{ ok: false, error: String((e && e.stack) || e) }}; }} + function (e) {{ settle({{ ok: false, error: String((e && e.stack) || e) }}); }} ); return "started"; }})()"# @@ -385,8 +391,35 @@ fn result_poll(key: &str) -> String { ) } +/// 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( From 684fd79dcefc28be645aa33c938aa7dc098d8aa7 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Tue, 6 Oct 2026 23:49:26 -0400 Subject: [PATCH 09/24] Open terminal links through xterm's linkHandler instead of a Vite opener shim Removes the build-time alias of the opener plugin; openLink now calls the plugin directly and the terminal routes link activation through it. Co-Authored-By: Claude Sonnet 5.5 --- src/features/browser/model/openLink.ts | 4 +-- src/features/terminal/ui/TerminalView.tsx | 8 +++++ src/platform/tauri/opener.test.ts | 29 ----------------- src/platform/tauri/opener.ts | 39 ----------------------- vite.config.ts | 27 ++-------------- 5 files changed, 12 insertions(+), 95 deletions(-) delete mode 100644 src/platform/tauri/opener.test.ts delete mode 100644 src/platform/tauri/opener.ts diff --git a/src/features/browser/model/openLink.ts b/src/features/browser/model/openLink.ts index 0465e4d8e1..79659d0ab9 100644 --- a/src/features/browser/model/openLink.ts +++ b/src/features/browser/model/openLink.ts @@ -1,4 +1,4 @@ -import { openExternalUrl } from "../../../platform/tauri/opener"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { IS_MAC } from "../../../platform/tauri/platform"; import { loadOpenLinksInApp } from "../../settings/model/displayPrefs"; import { isBrowsableUrl } from "./browserUrl"; @@ -36,5 +36,5 @@ export function openLink( ) { return Promise.resolve(); } - return openExternalUrl(url); + return openUrl(url); } diff --git a/src/features/terminal/ui/TerminalView.tsx b/src/features/terminal/ui/TerminalView.tsx index 58ad227111..874542e734 100644 --- a/src/features/terminal/ui/TerminalView.tsx +++ b/src/features/terminal/ui/TerminalView.tsx @@ -26,6 +26,7 @@ import { type TerminalFitMode, } from "../model/terminalLayout"; import { IS_MAC } from "../../../platform/tauri/platform"; +import { openLink } from "../../browser/model/openLink"; import "@xterm/xterm/css/xterm.css"; type Props = { @@ -172,6 +173,13 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { smoothScrollDuration: 0, theme: terminalTheme(isLightScheme()), macOptionIsMeta: IS_MAC, + linkHandler: { + activate: (event, uri) => { + void openLink(uri, event).catch((error) => { + console.error("Failed to open web link:", error); + }); + }, + }, }); term.open(host); termRef.current = term; diff --git a/src/platform/tauri/opener.test.ts b/src/platform/tauri/opener.test.ts deleted file mode 100644 index 7fab59aedf..0000000000 --- a/src/platform/tauri/opener.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); -vi.mock("../../features/browser/model/openLink", () => ({ openLink: vi.fn() })); - -import { terminalLinkClick } from "./opener"; - -describe("terminalLinkClick", () => { - it("recognizes clicks inside a terminal only", () => { - const terminal = document.createElement("div"); - terminal.className = "xterm"; - const cell = document.createElement("span"); - terminal.append(cell); - const elsewhere = document.createElement("button"); - document.body.append(terminal, elsewhere); - - const inside = new MouseEvent("click", { bubbles: true }); - cell.dispatchEvent(inside); - expect(terminalLinkClick(inside)).toBe(inside); - - const outside = new MouseEvent("click", { bubbles: true }); - elsewhere.dispatchEvent(outside); - expect(terminalLinkClick(outside)).toBeNull(); - - expect(terminalLinkClick(new KeyboardEvent("keydown"))).toBeNull(); - expect(terminalLinkClick(undefined)).toBeNull(); - }); -}); diff --git a/src/platform/tauri/opener.ts b/src/platform/tauri/opener.ts deleted file mode 100644 index 192197cb4c..0000000000 --- a/src/platform/tauri/opener.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * App-wide stand-in for `@tauri-apps/plugin-opener`. vite.config.ts resolves - * every import of that package to this file (this file alone gets the real - * one), so links a terminal opens with plain `openUrl` follow the in-app link - * setting without the terminal code knowing about the browser. Everything - * else passes straight through. - */ -import * as opener from "@tauri-apps/plugin-opener"; -import { openLink } from "../../features/browser/model/openLink"; - -export * from "@tauri-apps/plugin-opener"; - -/** The real opener: always the system default app. */ -export const openExternalUrl: typeof opener.openUrl = (url, openWith) => - opener.openUrl(url, openWith); - -/** The mouse event behind this call, when a terminal link was clicked. */ -export function terminalLinkClick( - event: Event | undefined, -): MouseEvent | null { - if (typeof MouseEvent === "undefined" || !(event instanceof MouseEvent)) { - return null; - } - const target = event.target; - if (typeof Element === "undefined" || !(target instanceof Element)) { - return null; - } - return target.closest(".xterm") ? event : null; -} - -export const openUrl: typeof opener.openUrl = (url, openWith) => { - const click = - openWith || typeof window === "undefined" - ? null - : // xterm activates links synchronously inside the DOM click. - terminalLinkClick(window.event); - if (click) return openLink(String(url), click); - return openExternalUrl(url, openWith); -}; diff --git a/vite.config.ts b/vite.config.ts index 99519ced89..2f7ee41d57 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,29 +1,6 @@ -import { defineConfig, type Plugin } from "vite"; +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { fileURLToPath } from "node:url"; - -const OPENER = "@tauri-apps/plugin-opener"; -const OPENER_SHIM = fileURLToPath( - new URL("./src/platform/tauri/opener.ts", import.meta.url), -); - -/** - * Send every app import of the opener plugin through the in-app shim, so - * links clicked in a terminal can open in the built-in browser. The shim - * itself still gets the real package. - */ -function routeOpener(): Plugin { - return { - name: "monocode:route-opener", - enforce: "pre", - resolveId(source, importer) { - if (source !== OPENER || !importer) return null; - if (importer.split("?")[0] === OPENER_SHIM) return null; - return OPENER_SHIM; - }, - }; -} const host = process.env.TAURI_DEV_HOST; @@ -31,7 +8,7 @@ export default defineConfig(async ({ mode }) => { const stable = mode === "stable"; return { - plugins: [routeOpener(), react(), tailwindcss()], + plugins: [react(), tailwindcss()], clearScreen: false, build: { rollupOptions: { From ffdc757b98d09d380d707c571e50fd7dd8f99f26 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 00:00:45 -0400 Subject: [PATCH 10/24] Address embedded-browser review: async open, window-scoped composer, honest load waits - browser_open is async, with lookup and child creation under one lock - quick-composer prepare/take/ack take Window, and liveness uses get_window - explicit navigate/open/reload await a real load or report timedOut - poll native URL so pushState/replaceState/hash changes are tracked - summarize the page after its load finishes, not before Co-Authored-By: Claude Sonnet 5.5 --- src-tauri/src/browser.rs | 23 ++- src-tauri/src/lib.rs | 1 + src-tauri/src/quick_composer.rs | 10 +- .../browser/model/browserAgent.test.ts | 121 +++++++++++++++- src/features/browser/model/browserAgent.ts | 82 +++++++++-- .../browser/model/browserNavigation.ts | 5 + src/features/browser/model/browserStore.ts | 30 +++- src/features/browser/model/nativeUrl.test.ts | 137 ++++++++++++++++++ src/features/browser/model/nativeUrl.ts | 67 +++++++++ .../browser/ui/SessionBrowserDock.tsx | 17 +++ src/platform/tauri/browser.ts | 5 + 11 files changed, 467 insertions(+), 31 deletions(-) create mode 100644 src/features/browser/model/nativeUrl.test.ts create mode 100644 src/features/browser/model/nativeUrl.ts diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 4011b4a5c9..fcd49413ad 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -2,7 +2,7 @@ //! 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; +use std::sync::{mpsc, Mutex}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -19,6 +19,10 @@ 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, @@ -155,8 +159,10 @@ fn place(webview: &Webview, bounds: Bounds) -> Result<(), String> { .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 fn browser_open( +pub async fn browser_open( app: AppHandle, window: Window, id: String, @@ -165,6 +171,7 @@ pub fn browser_open( 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 { @@ -245,6 +252,18 @@ pub fn browser_open( 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) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69988acbf6..07d41d5eb2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -263,6 +263,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ browser::browser_open, + browser::browser_url, browser::browser_set_bounds, browser::browser_set_visible, browser::browser_focus, diff --git a/src-tauri/src/quick_composer.rs b/src-tauri/src/quick_composer.rs index 3e65e682d3..02558f4a17 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()); } @@ -425,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()) { @@ -436,13 +436,13 @@ pub fn quick_composer_take( .lock() .map_err(|err| err.to_string())? .claim(window.label(), |label| { - app.get_webview_window(label).is_some() + app.get_window(label).is_some() })) } #[tauri::command] pub fn quick_composer_ack( - window: WebviewWindow, + window: Window, state: State<'_, QuickComposerState>, id: String, ) -> Result<(), String> { diff --git a/src/features/browser/model/browserAgent.test.ts b/src/features/browser/model/browserAgent.test.ts index 3e163e7dc5..a884d41bb1 100644 --- a/src/features/browser/model/browserAgent.test.ts +++ b/src/features/browser/model/browserAgent.test.ts @@ -434,7 +434,7 @@ describe("browser tools waiting on a tab", () => { expect(call.value).not.toHaveProperty("timedOut"); }); - it("reloads without load events and leaves the tab usable", async () => { + it("does not report a reload as loaded until the load runs", async () => { const id = openTab(); setNativeTabReady(id, true); const call = track( @@ -443,13 +443,122 @@ describe("browser tools waiting on a tab", () => { action: "reload", }), ); - await vi.advanceTimersByTimeAsync(1_600); - expect(call.done).toBe(true); - expect(call.error).toBeUndefined(); + // 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(); - await evalCall(id); - expect(evals).toEqual([id]); + }); + + 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 () => { diff --git a/src/features/browser/model/browserAgent.ts b/src/features/browser/model/browserAgent.ts index 583cd90081..50805de877 100644 --- a/src/features/browser/model/browserAgent.ts +++ b/src/features/browser/model/browserAgent.ts @@ -77,6 +77,22 @@ function summary(id: string) { return { tabId: id, url: tab?.url, title: tab?.title || undefined }; } +/** Whether `to` differs from `from` only by its fragment. */ +function isSameDocument(from: string | undefined, to: string): boolean { + if (!from) return false; + try { + const a = new URL(from); + const b = new URL(to); + if (!b.hash) return false; + a.hash = ""; + const target = new URL(b); + target.hash = ""; + return a.href === target.href; + } catch { + return false; + } +} + function pageUrl(input: Input): string { const url = browserUrlFromInput(required(input, "url")); if (!isBrowsableUrl(url)) throw new Error("Only http(s) pages can be opened"); @@ -85,18 +101,31 @@ function pageUrl(input: Input): string { 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; +}; + /** - * Resolve once a load that starts within `startWithinMs` finishes, or right - * away when none starts (a hash change, a click that did not navigate, a - * reused view that already finished). Rejects if the tab goes away and reports + * 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 } = {}, + { startWithinMs = 1_500, timeoutMs = LOAD_TIMEOUT_MS, since }: LoadOptions = {}, ): Promise { return new Promise((resolve, reject) => { - let started = !!tabSource(id)?.loading; + const sawLoad = () => since !== undefined && loadGeneration(id) > since; + let started = !!tabSource(id)?.loading || sawLoad(); let unsubscribe = () => {}; const finish = (result: LoadResult, closed = false) => { unsubscribe(); @@ -108,12 +137,15 @@ function waitForLoad( const check = () => { const tab = tabSource(id); if (!tab) return finish({}, true); - if (tab.loading) started = true; - else if (started) finish({}); + if (tab.loading || sawLoad()) started = true; + if (started && !tab.loading) finish({}); }; - const startTimer = setTimeout(() => { - if (!started) finish({}); - }, startWithinMs); + const startTimer = + since === undefined + ? setTimeout(() => { + if (!started) finish({}); + }, startWithinMs) + : undefined; const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs); unsubscribe = subscribeBrowser(check); check(); @@ -150,9 +182,15 @@ function waitForNative(id: string, timeoutMs = 8_000): Promise { 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); @@ -172,9 +210,9 @@ async function reconcileLoad( /** Wait for a load, then settle a timeout against the page's real state. */ async function waitForPage( id: string, - options?: Parameters[1], + options?: LoadOptions, ): Promise { - return reconcileLoad(id, await waitForLoad(id, options)); + return reconcileLoad(id, await waitForLoad(id, options), options?.since); } /** Throw unless a wait ended with the page loaded. */ @@ -268,16 +306,24 @@ export function createBrowserAgent(sessionId: string) { 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, { startWithinMs: 3_000 }); + 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. + const sameDocument = isSameDocument(tabSource(id)?.url, url); + const since = loadGeneration(id); await navigateBrowserTab(id, url); - return { ...summary(id), ...(await waitForPage(id)) }; + const load = await waitForPage( + id, + sameDocument ? undefined : { since }, + ); + return { ...summary(id), ...load }; }, async browser_history(input) { @@ -286,8 +332,14 @@ export function createBrowserAgent(sessionId: string) { throw new Error("action must be back, forward, or reload"); } const id = await useTab(input); + const since = loadGeneration(id); await browserHistory(id, action); - return { ...summary(id), ...(await waitForPage(id)) }; + // 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) { diff --git a/src/features/browser/model/browserNavigation.ts b/src/features/browser/model/browserNavigation.ts index dd4ed64c84..3c793a1189 100644 --- a/src/features/browser/model/browserNavigation.ts +++ b/src/features/browser/model/browserNavigation.ts @@ -23,6 +23,11 @@ interface Inflight { const inflight = new Map(); +/** Whether a navigation request for the tab is still unsettled. */ +export function isNavigating(id: string): boolean { + return inflight.has(id); +} + /** Native load events own loading: hash changes and rejected loads may emit none. */ export async function navigateBrowserTab( id: string, diff --git a/src/features/browser/model/browserStore.ts b/src/features/browser/model/browserStore.ts index 2f80070d5f..201036b39f 100644 --- a/src/features/browser/model/browserStore.ts +++ b/src/features/browser/model/browserStore.ts @@ -501,7 +501,13 @@ 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) return; + 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)), ); @@ -511,6 +517,9 @@ function pruneLoadGenerations(current: BrowserState) { 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 { @@ -524,12 +533,24 @@ export function nativeUrlEpoch(fileId: string): number { return nativeUrlEpochs.get(fileId) ?? 0; } -/** Record a native load finish: its URL (e.g. a redirect) is authoritative. */ -export function noteLoadFinished(fileId: string) { +/** 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; @@ -551,6 +572,9 @@ export function patchBrowserTab( fileId: string, patch: Partial, ) { + if (patch.url !== undefined) { + urlRevisions.set(fileId, urlRevision(fileId) + 1); + } update((current) => patchBrowserTabIn(current, fileId, patch)); } diff --git a/src/features/browser/model/nativeUrl.test.ts b/src/features/browser/model/nativeUrl.test.ts new file mode 100644 index 0000000000..aa7da16933 --- /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); + } +}); + +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, async () => 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, async () => 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, async () => "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, async () => "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/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx index 9bc64912e5..f6a3154715 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -10,6 +10,7 @@ import { DockPanel } from "../../workspace/ui/DockPanel"; import { applyDockGridStyle } from "../../projects/model/projectTerminal"; import { onBrowserEvent, + readBrowserUrl, retainBrowserViews, } from "../../../platform/tauri/browser"; import { @@ -34,8 +35,12 @@ import { 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 }; @@ -118,7 +123,19 @@ function useBrowserEvents() { }); } }); + // 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()); }; }, []); diff --git a/src/platform/tauri/browser.ts b/src/platform/tauri/browser.ts index fe842f15dd..0c51b3c98e 100644 --- a/src/platform/tauri/browser.ts +++ b/src/platform/tauri/browser.ts @@ -22,6 +22,11 @@ export function openBrowserView( 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, From 16f7e1ffa088c321c07eee5a186f155e3bbe0f04 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 01:39:09 -0400 Subject: [PATCH 11/24] Report browser navigation outcomes from the page and gate overlapping dispatch - Fragment targets are offered to the page, which reports where its URL ended up. A synchronous change means the page stayed in its document (applied, with the resulting URL). An unchanged URL is unknown: the page refused or started a load that has not committed yet. Probes carry a start deadline so a late one does not touch the page. - An unanswered probe is unknown only when the target differs from the webview's URL by fragment alone; otherwise it is a document navigation. - Navigations dispatch one at a time per tab and take their load baseline at dispatch. A navigation waits up to 2s for the previous one's load to start; if it never does, the result is flagged as overlapped and the agent warns. - Settling entries expire (30s document, 3s unknown) as recovery for loads that never report a start. - Run the generated probe in Node across handler behaviors, and cover the dispatch queue with tests that use the real platform module. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/browser.rs | 354 +++++++++++++++- .../browser/model/browserAgent.test.ts | 62 +++ src/features/browser/model/browserAgent.ts | 55 +-- .../model/browserNavigation.queue.test.ts | 124 ++++++ .../browser/model/browserNavigation.test.ts | 391 +++++++++++++++++- .../browser/model/browserNavigation.ts | 195 ++++++++- src/features/browser/model/nativeUrl.test.ts | 12 +- src/platform/tauri/browser.test.ts | 68 +++ src/platform/tauri/browser.ts | 44 +- 9 files changed, 1253 insertions(+), 52 deletions(-) create mode 100644 src/features/browser/model/browserNavigation.queue.test.ts create mode 100644 src/platform/tauri/browser.test.ts diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index fcd49413ad..ec52751a96 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -285,13 +285,189 @@ 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, + } +} + +/// Whether `target` differs from `current` only by its fragment. Used only when +/// the page did not answer: the webview's own URL can lag the page's +/// same-document changes, so it is not trusted over the page. +fn fragment_only_change(current: &Url, target: &Url) -> bool { + let (mut a, mut b) = (current.clone(), target.clone()); + a.set_fragment(None); + b.set_fragment(None); + a == b +} + +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 fn browser_navigate(app: AppHandle, id: String, url: String) -> Result<(), String> { +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()); } - find(&app, &id)?.navigate(url).map_err(|e| e.to_string()) + 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 => {} + Probed::Unanswered => { + // A hung or blank page is most likely elsewhere entirely; only + // a target in the webview's current document may land without + // a load. + if webview + .url() + .map_or(true, |current| fragment_only_change(¤t, &url)) + { + outcome = Outcome::Unknown; + } + } + } + } + webview.navigate(url).map_err(|e| e.to_string())?; + Ok(Navigation::of(outcome)) } #[derive(Deserialize)] @@ -571,6 +747,180 @@ pub fn browser_retain(window: Window, ids: Vec) -> Result<(), String> { 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" }) + ); + } + + #[test] + fn only_a_fragment_change_of_the_current_document_is_unknown() { + let u = |s: &str| Url::parse(s).unwrap(); + assert!(fragment_only_change( + &u("https://a.test/p"), + &u("https://a.test/p#x") + )); + assert!(fragment_only_change( + &u("https://a.test/p#y"), + &u("https://a.test/p#x") + )); + assert!(!fragment_only_change( + &u("about:blank"), + &u("https://a.test/p#x") + )); + assert!(!fragment_only_change( + &u("https://a.test/q"), + &u("https://a.test/p#x") + )); + } + + /// 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"); diff --git a/src/features/browser/model/browserAgent.test.ts b/src/features/browser/model/browserAgent.test.ts index a884d41bb1..d92f82ef31 100644 --- a/src/features/browser/model/browserAgent.test.ts +++ b/src/features/browser/model/browserAgent.test.ts @@ -394,9 +394,48 @@ describe("browser tools waiting on a tab", () => { 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, @@ -410,11 +449,34 @@ describe("browser tools waiting on a tab", () => { 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); diff --git a/src/features/browser/model/browserAgent.ts b/src/features/browser/model/browserAgent.ts index 50805de877..979c0793b7 100644 --- a/src/features/browser/model/browserAgent.ts +++ b/src/features/browser/model/browserAgent.ts @@ -77,22 +77,6 @@ function summary(id: string) { return { tabId: id, url: tab?.url, title: tab?.title || undefined }; } -/** Whether `to` differs from `from` only by its fragment. */ -function isSameDocument(from: string | undefined, to: string): boolean { - if (!from) return false; - try { - const a = new URL(from); - const b = new URL(to); - if (!b.hash) return false; - a.hash = ""; - const target = new URL(b); - target.hash = ""; - return a.href === target.href; - } catch { - return false; - } -} - function pageUrl(input: Input): string { const url = browserUrlFromInput(required(input, "url")); if (!isBrowsableUrl(url)) throw new Error("Only http(s) pages can be opened"); @@ -110,6 +94,11 @@ type LoadOptions = { * 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; }; /** @@ -121,7 +110,12 @@ type LoadOptions = { */ function waitForLoad( id: string, - { startWithinMs = 1_500, timeoutMs = LOAD_TIMEOUT_MS, since }: LoadOptions = {}, + { + startWithinMs = 1_500, + timeoutMs = LOAD_TIMEOUT_MS, + since, + maybeLoads = false, + }: LoadOptions = {}, ): Promise { return new Promise((resolve, reject) => { const sawLoad = () => since !== undefined && loadGeneration(id) > since; @@ -141,7 +135,7 @@ function waitForLoad( if (started && !tab.loading) finish({}); }; const startTimer = - since === undefined + since === undefined || maybeLoads ? setTimeout(() => { if (!started) finish({}); }, startWithinMs) @@ -315,15 +309,28 @@ export function createBrowserAgent(sessionId: string) { 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. - const sameDocument = isSameDocument(tabSource(id)?.url, url); - const since = loadGeneration(id); - await navigateBrowserTab(id, url); + // 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, - sameDocument ? undefined : { since }, + outcome === "document" + ? { since } + : outcome === "unknown" + ? { since, maybeLoads: true } + : undefined, ); - return { ...summary(id), ...load }; + 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) { 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 index 81b449542b..c8dc24caf2 100644 --- a/src/features/browser/model/browserNavigation.test.ts +++ b/src/features/browser/model/browserNavigation.test.ts @@ -1,13 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type Reply = + { outcome: "applied" | "document" | "unknown"; url?: string } | undefined; const pending: Array<{ - resolve: () => void; + /** 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( - () => - new Promise((resolve, reject) => pending.push({ resolve, reject })), + (_id: string, _url: string, onDispatch?: () => void) => + new Promise((resolve, reject) => { + onDispatch?.(); + pending.push({ + resolve: (outcome) => resolve(outcome ?? { outcome: "document" }), + reject, + }); + }), ), })); @@ -20,7 +31,19 @@ import { openBrowserTab, patchBrowserTab, } from "./browserStore"; -import { navigateBrowserTab } from "./browserNavigation"; +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) @@ -234,4 +257,364 @@ describe("navigateBrowserTab rollback", () => { 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 index 3c793a1189..6912a73e88 100644 --- a/src/features/browser/model/browserNavigation.ts +++ b/src/features/browser/model/browserNavigation.ts @@ -1,10 +1,14 @@ -import { navigateBrowser } from "../../../platform/tauri/browser"; +import { + navigateBrowser, + type BrowserNavigation, +} from "../../../platform/tauri/browser"; import { dockOfTab, getBrowserState, loadGeneration, nativeUrlEpoch, patchBrowserTab, + subscribeBrowser, } from "./browserStore"; interface Inflight { @@ -23,19 +27,146 @@ interface Inflight { const inflight = new Map(); -/** Whether a navigation request for the tab is still unsettled. */ +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 { - return inflight.has(id); + 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); } -/** Native load events own loading: hash changes and rejected loads may emit none. */ +/** + * 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 { +): Promise { const browser = () => dockOfTab(getBrowserState(), id)?.pane.files.find((file) => file.id === id) ?.browser; + pruneSettling(); let entry = inflight.get(id); if (!entry) { entry = { @@ -53,17 +184,56 @@ export async function navigateBrowserTab( 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 generation = loadGeneration(id); + 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 { - await navigateBrowser(id, url); + 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 = url; + 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. @@ -75,15 +245,12 @@ export async function navigateBrowserTab( nativeUrlEpoch(id) === epoch && browser() ) { - patchBrowserTab(id, { url }); + patchBrowserTab(id, { url: landed }); } } + return { outcome, since: generation, overlapped }; } catch (reason) { - const current = - state.latest === request && - nativeUrlEpoch(id) === epoch && - !!dockOfTab(getBrowserState(), id); - if (current) { + if (current()) { state.restoredBy = request; state.restoredEpoch = epoch; patchBrowserTab(id, { diff --git a/src/features/browser/model/nativeUrl.test.ts b/src/features/browser/model/nativeUrl.test.ts index aa7da16933..5ae665cddc 100644 --- a/src/features/browser/model/nativeUrl.test.ts +++ b/src/features/browser/model/nativeUrl.test.ts @@ -34,6 +34,8 @@ afterEach(() => { } }); +const at = (url: string) => async () => url; + describe("same-document URL tracking", () => { it.each([ ["pushState", "https://example.com/app/items/7"], @@ -43,7 +45,7 @@ describe("same-document URL tracking", () => { const id = openLive(); patchBrowserTab(id, { title: "App" }); const epoch = nativeUrlEpoch(id); - expect(await syncNativeUrl(id, async () => url)).toBe(true); + expect(await syncNativeUrl(id, at(url))).toBe(true); expect(tab(id)).toMatchObject({ url, title: "App", loading: false }); expect(nativeUrlEpoch(id)).toBe(epoch + 1); }); @@ -51,7 +53,7 @@ describe("same-document URL tracking", () => { it("changes nothing when the URL is the same", async () => { const id = openLive(); const epoch = nativeUrlEpoch(id); - expect(await syncNativeUrl(id, async () => tab(id)!.url)).toBe(false); + expect(await syncNativeUrl(id, at(tab(id)!.url))).toBe(false); expect(nativeUrlEpoch(id)).toBe(epoch); }); @@ -102,9 +104,7 @@ describe("same-document URL tracking", () => { throw new Error("Browser tab is not open"); }), ).toBe(false); - expect(await syncNativeUrl(id, async () => "file:///etc/passwd")).toBe( - false, - ); + expect(await syncNativeUrl(id, at("file:///etc/passwd"))).toBe(false); }); it("tracks only live tabs with a native page", () => { @@ -124,7 +124,7 @@ describe("same-document URL tracking", () => { it("restores a suspended tab at its pushState URL", async () => { const id = openLive(); - await syncNativeUrl(id, async () => "https://example.com/app/items/7"); + 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()))), 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 index 0c51b3c98e..d5a765fc32 100644 --- a/src/platform/tauri/browser.ts +++ b/src/platform/tauri/browser.ts @@ -42,8 +42,48 @@ export function focusBrowserView(id: string): Promise { return invoke("browser_focus", { id }); } -export function navigateBrowser(id: string, url: string): Promise { - return invoke("browser_navigate", { id, url }); +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( From 234a5ee01a55d48a4a13c1555f5ae1ea79c83940 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 01:41:26 -0400 Subject: [PATCH 12/24] Apply rustfmt to quick_composer Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/quick_composer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/quick_composer.rs b/src-tauri/src/quick_composer.rs index 02558f4a17..7b87c5afe9 100644 --- a/src-tauri/src/quick_composer.rs +++ b/src-tauri/src/quick_composer.rs @@ -435,9 +435,7 @@ pub fn quick_composer_take( .pending .lock() .map_err(|err| err.to_string())? - .claim(window.label(), |label| { - app.get_window(label).is_some() - })) + .claim(window.label(), |label| app.get_window(label).is_some())) } #[tauri::command] From 9b1f554a710e2711bc5e7e230766cd31139df7ea Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 01:56:53 -0400 Subject: [PATCH 13/24] Stop the opener plugin from swallowing link clicks in browser tabs tauri-plugin-opener injects a click handler into every webview, including built-in browser pages. On a plain click of a target=_blank link it cancels the click and invokes plugin:opener|open_url, which remote pages may not call, so nothing happened; Cmd-click bypassed it and opened a tab. The app's own links open explicitly, so disable the injected script. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 07d41d5eb2..6d83cc1939 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,7 +218,15 @@ pub fn run() { let app = tauri::Builder::default() .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() From 536209715e54511c2942b294a0e52f59bcbaf101 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 12:24:44 -0400 Subject: [PATCH 14/24] Ask before opening terminal links that hide their target An OSC 8 hyperlink can show one address and open another. Our linkHandler replaced xterm's default, so nothing warned about that; xterm's own prompt uses window.confirm, which the webview never shows. Ask through the native dialog, with the target in the message, whenever the link's visible text differs from its target. Co-Authored-By: Claude Opus 5.5 --- .../terminal/model/terminalLinks.test.ts | 24 +++++++++++++++++++ src/features/terminal/model/terminalLinks.ts | 18 ++++++++++++++ src/features/terminal/ui/TerminalView.tsx | 19 +++++++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/features/terminal/model/terminalLinks.test.ts create mode 100644 src/features/terminal/model/terminalLinks.ts 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/TerminalView.tsx b/src/features/terminal/ui/TerminalView.tsx index 874542e734..386b2b282e 100644 --- a/src/features/terminal/ui/TerminalView.tsx +++ b/src/features/terminal/ui/TerminalView.tsx @@ -26,7 +26,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 = { @@ -174,8 +176,21 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { theme: terminalTheme(isLightScheme()), macOptionIsMeta: IS_MAC, linkHandler: { - activate: (event, uri) => { - void openLink(uri, event).catch((error) => { + // Replaces xterm's own prompt, which uses window.confirm: the webview + // has no confirm panel, so that never opens anything. + activate: (event, uri, range) => { + void (async () => { + if ( + oscLinkNeedsConfirm(oscLinkLabel(term, range), uri) && + !(await ask(`Open this terminal link?\n\n${uri}`, { + title: "MonoCode", + kind: "warning", + })) + ) { + return; + } + await openLink(uri, event); + })().catch((error) => { console.error("Failed to open web link:", error); }); }, From 4fbba03a20b9024ad050d536affe385e8ebc050a Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 12:29:08 -0400 Subject: [PATCH 15/24] Forget a session's browser tabs only when it is deleted App inferred deletion from a session leaving the sessions list, but idle detachment also removes saved sessions from it, so reopening one lost its browser tabs. Forget them in deleteSession instead, after the delete succeeds; every deletion path goes through it. Co-Authored-By: Claude Opus 5.5 --- src/app/App.tsx | 11 ----- src/features/sessions/data/sessionStore.ts | 4 ++ .../sessions/data/sessionStoreBrowser.test.ts | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 src/features/sessions/data/sessionStoreBrowser.test.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index ce356f1d2e..50276b4932 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -641,7 +641,6 @@ 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 { forgetBrowserSessions } from "../features/browser/model/browserStore"; import { handleBrowserRequest } from "../features/browser/model/browserAgent"; import { lazySurface } from "../shared/ui/lazySurface"; @@ -1717,16 +1716,6 @@ function Workspace({ : undefined; const monoCovers = !!monoViewId; - // A deleted session takes its browser tabs with it. - const browserSessionIds = useRef | null>(null); - useEffect(() => { - const ids = new Set(sessions.map((session) => session.id)); - const previous = browserSessionIds.current; - browserSessionIds.current = ids; - if (!previous) return; - const gone = [...previous].filter((id) => !ids.has(id)); - if (gone.length > 0) forgetBrowserSessions(gone); - }, [sessions]); const activeTabSessionIds = activeTab ? leafIds(activeTab.layout) : []; const activeLinkedWorkItemPanel = activeTab ? (linkedWorkItemPanels.get(activeTab.focusedId) ?? diff --git a/src/features/sessions/data/sessionStore.ts b/src/features/sessions/data/sessionStore.ts index f565a35e48..b7bfd5a6a5 100644 --- a/src/features/sessions/data/sessionStore.ts +++ b/src/features/sessions/data/sessionStore.ts @@ -48,6 +48,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"; @@ -757,6 +758,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"]); + }); +}); From a42b8a6d9bf189685df7a754b028a85c469f5da6 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 12:31:52 -0400 Subject: [PATCH 16/24] Block the dev server in browser tabs only when the app is served from it app_origins always treated build.devUrl as an app origin, so builds that serve the app from tauri:// refused the user's own dev server on that port. Include it only when tauri::is_dev() (no custom-protocol), which is what decides whether the app loads from the dev server; debug_assertions does not. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/browser.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index ec52751a96..a023a133d3 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -77,8 +77,18 @@ fn parse_url(value: &str) -> Result { /// 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 { - let config = app.config(); - let mut own: Vec = config.build.dev_url.iter().cloned().collect(); + 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()); @@ -1061,6 +1071,24 @@ mod tests { )); } + #[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()]); From 1686e482539c208a09220ba816efc33810b87b9d Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 12:38:37 -0400 Subject: [PATCH 17/24] Send cwd and browser MCP servers on ACP session/resume FX and Grok resumed with only the session ID, so a resumed session never got its browser MCP server; Antigravity already sent both. Resolve the server list once per startup in all three providers and reuse it for resume, load and new, instead of asking the native side again for each request. Co-Authored-By: Claude Opus 5.5 --- .../providers/antigravity/antigravity.ts | 8 +++-- src/integrations/harness/providers/fx/fx.ts | 7 ++-- .../harness/providers/fx/fxLive.test.ts | 32 ++++++++++++++++- .../harness/providers/grok/grok.ts | 7 ++-- .../harness/providers/grok/grokLive.test.ts | 34 +++++++++++++++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/integrations/harness/providers/antigravity/antigravity.ts b/src/integrations/harness/providers/antigravity/antigravity.ts index df67a78c9b..db18b937fe 100644 --- a/src/integrations/harness/providers/antigravity/antigravity.ts +++ b/src/integrations/harness/providers/antigravity/antigravity.ts @@ -489,12 +489,14 @@ async function startLive(input: SendTurnInput, life: number): Promise { 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: await acpBrowserMcpServersFor(input.sessionId) }, + { sessionId: resume.acpSessionId, cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; @@ -513,7 +515,7 @@ async function startLive(input: SendTurnInput, life: number): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: await acpBrowserMcpServersFor(input.sessionId), + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -534,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: await acpBrowserMcpServersFor(input.sessionId) }, + { cwd: input.cwd, mcpServers }, SESSION_TIMEOUT_MS, ); acpSessionId = sessionIdFromResult(setup); diff --git a/src/integrations/harness/providers/fx/fx.ts b/src/integrations/harness/providers/fx/fx.ts index 767c1097db..ba7636d55a 100644 --- a/src/integrations/harness/providers/fx/fx.ts +++ b/src/integrations/harness/providers/fx/fx.ts @@ -288,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; @@ -306,7 +307,7 @@ async function ensureLive(input: SendTurnInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: await acpBrowserMcpServersFor(input.sessionId), + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -325,7 +326,7 @@ async function ensureLive(input: SendTurnInput): Promise { if (!acpSessionId) { setup = await acp.request( "session/new", - { cwd: input.cwd, mcpServers: await acpBrowserMcpServersFor(input.sessionId) }, + { 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 21e57fea94..8dc3fc225e 100644 --- a/src/integrations/harness/providers/grok/grok.ts +++ b/src/integrations/harness/providers/grok/grok.ts @@ -335,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; @@ -353,7 +354,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { { sessionId: resume.acpSessionId, cwd: input.cwd, - mcpServers: await acpBrowserMcpServersFor(input.sessionId), + mcpServers, }, SESSION_TIMEOUT_MS, ); @@ -376,7 +377,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { grokSessionNewParams( input.cwd, input.runtimeMode, - await acpBrowserMcpServersFor(input.sessionId), + mcpServers, ), SESSION_TIMEOUT_MS, ); 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({ From f4cc52bba535f16b3a2961c991c1a364eeb6364a Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 12:38:37 -0400 Subject: [PATCH 18/24] Skip overlay-occlusion tracking for off-window browser tabs Background tabs render in a host outside the window, where no overlay can cover them, yet each ran a body-wide MutationObserver and layout reads on every DOM change. Mark those surfaces offWindow and leave tracking off. Co-Authored-By: Claude Opus 5.5 --- src/features/browser/ui/BrowserView.tsx | 5 ++++- src/features/browser/ui/SessionBrowserDock.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/browser/ui/BrowserView.tsx b/src/features/browser/ui/BrowserView.tsx index 81229a24d7..3604fa06cb 100644 --- a/src/features/browser/ui/BrowserView.tsx +++ b/src/features/browser/ui/BrowserView.tsx @@ -60,6 +60,7 @@ export function BrowserSurface({ id, tab, visible, + offWindow = false, resizing = false, className = "", }: { @@ -67,13 +68,15 @@ export function BrowserSurface({ 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; className?: string; }) { const host = useRef(null); const scale = useSyncExternalStore(subscribeUiScale, loadUiScale); - const covered = useOverlayOcclusion(host, visible); + const covered = useOverlayOcclusion(host, visible && !offWindow); const shown = visible && !covered; const [ready, setReady] = useState(false); const placed = useRef(null); diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx index f6a3154715..567b1aa271 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -238,6 +238,7 @@ export function BrowserDockLayout({ id={file.id} tab={file.browser} visible + offWindow className="absolute inset-0" /> ) : null, From b989d68b24a71eb76dbe07bead0ec33c8a87f501 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 13:06:53 -0400 Subject: [PATCH 19/24] Accept floating-Mono commands from workspace windows that host browser tabs A workspace window with a browser tab has child webviews, so it is no longer a WebviewWindow, and Tauri rejects commands taking a WebviewWindow argument with "current webview is not a WebviewWindow". The floating-Mono commands workspace windows call (sync, switch, publish, take, accept, reply, keep_alive) now take a Window; panel lookups use the label. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/mono_chat.rs | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/mono_chat.rs b/src-tauri/src/mono_chat.rs index 974b4eb1e0..99befb2b25 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}; @@ -339,7 +339,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 { @@ -347,12 +347,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()) } @@ -360,7 +360,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, @@ -523,7 +523,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::(); @@ -540,7 +540,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> { @@ -548,8 +548,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()); @@ -670,7 +670,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 || { @@ -707,7 +707,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 @@ -720,7 +720,7 @@ pub fn mono_chat_state(app: AppHandle, window: WebviewWindow) -> Result Result<(), String> { @@ -737,7 +737,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::() @@ -752,7 +752,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::() @@ -765,7 +765,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> { @@ -812,7 +812,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 @@ -877,7 +877,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| { From dec9ad85245f51988e798be09517f5059e5d53fc Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 13:16:54 -0400 Subject: [PATCH 20/24] Don't read the webview URL after an unanswered fragment probe On macOS, wry's Webview::url unwraps WKWebView.URL, which is nil while a page is blank or has not committed, the same state that leaves a probe unanswered, so a navigation could panic. Report the outcome as unknown instead, and drop the now-unused fragment_only_change. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/browser.rs | 46 ++++------------------------------------ 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index a023a133d3..1bd7265671 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -412,16 +412,6 @@ fn judge_probe(answer: &Result) -> Probed { } } -/// Whether `target` differs from `current` only by its fragment. Used only when -/// the page did not answer: the webview's own URL can lag the page's -/// same-document changes, so it is not trusted over the page. -fn fragment_only_change(current: &Url, target: &Url) -> bool { - let (mut a, mut b) = (current.clone(), target.clone()); - a.set_fragment(None); - b.set_fragment(None); - a == b -} - fn now_ms() -> u128 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -463,17 +453,10 @@ pub async fn browser_navigate( match judge_probe(&answer) { Probed::Settled(navigation) => return Ok(navigation), Probed::Document => {} - Probed::Unanswered => { - // A hung or blank page is most likely elsewhere entirely; only - // a target in the webview's current document may land without - // a load. - if webview - .url() - .map_or(true, |current| fragment_only_change(¤t, &url)) - { - outcome = Outcome::Unknown; - } - } + // 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())?; @@ -812,27 +795,6 @@ mod tests { ); } - #[test] - fn only_a_fragment_change_of_the_current_document_is_unknown() { - let u = |s: &str| Url::parse(s).unwrap(); - assert!(fragment_only_change( - &u("https://a.test/p"), - &u("https://a.test/p#x") - )); - assert!(fragment_only_change( - &u("https://a.test/p#y"), - &u("https://a.test/p#x") - )); - assert!(!fragment_only_change( - &u("about:blank"), - &u("https://a.test/p#x") - )); - assert!(!fragment_only_change( - &u("https://a.test/q"), - &u("https://a.test/p#x") - )); - } - /// 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> { From 903ea6296e042f529366ee481b7fed000b21232e Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 13:20:25 -0400 Subject: [PATCH 21/24] Let BrowserSurface's className replace its default layout Background surfaces pass "absolute inset-0", but the default "relative min-h-0 flex-1" was appended too, and Tailwind emits .relative after .absolute, so each background page stayed a flex item sharing the 1280px off-window host. A caller's className now replaces the default layout; the background color always applies. Co-Authored-By: Claude Opus 5.5 --- src/features/browser/ui/BrowserView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/browser/ui/BrowserView.tsx b/src/features/browser/ui/BrowserView.tsx index 3604fa06cb..2fb493010c 100644 --- a/src/features/browser/ui/BrowserView.tsx +++ b/src/features/browser/ui/BrowserView.tsx @@ -72,6 +72,7 @@ export function BrowserSurface({ 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); @@ -182,7 +183,9 @@ export function BrowserSurface({ return (
{tab.error ? (
From 2ed5b31e3a9b8c60b5add7b132284b151f62e6d7 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 14:25:03 -0400 Subject: [PATCH 22/24] Allow frame documents through the browser navigation hook On macOS and Linux, wry sends subframe navigations to this hook with no frame to tell them apart, so srcdoc iframes, about:blank with a query or fragment, and blob: frames were cancelled. Admit about:blank and about:srcdoc, and blob: URLs of a web origin other than the app's. data: stays blocked, since the hook would also allow it top-level. Typed and agent URLs still go through parse_url. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/browser.rs | 47 +++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 1bd7265671..9be8e048d4 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -124,7 +124,22 @@ fn is_internal_origin(url: &Url) -> bool { /// Every navigation, including redirects and link clicks, passes through here. fn allow_navigation(app: &AppHandle, url: &Url) -> bool { - parse_url(url.as_str()).is_ok() && !is_app_origin(app, url) + 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 @@ -1105,16 +1120,38 @@ mod tests { } #[test] - fn redirect_targets_are_vetted_like_typed_urls() { - // `allow_navigation` applies `parse_url` to every redirect target. + 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", - "blob:https://a/b", + "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!(parse_url(blocked).is_err(), "{blocked}"); + 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] From 53d290f63df350bfd1f1c475a8081ea48979c47b Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Thu, 8 Oct 2026 10:40:02 -0400 Subject: [PATCH 23/24] Keep off-window browser pages outside the hidden workspace Search, settings, the inbox, notes and automations hide the workspace with display: none. The off-window host for background tabs was inside it, so those pages measured 1x1, and an agent working meanwhile got a one-pixel viewport. Portal the host to document.body; it is position: fixed either way. Co-Authored-By: Claude Opus 5.5 --- .../browser/ui/SessionBrowserDock.test.ts | 24 ++++++++ .../browser/ui/SessionBrowserDock.tsx | 56 ++++++++++--------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/features/browser/ui/SessionBrowserDock.test.ts b/src/features/browser/ui/SessionBrowserDock.test.ts index db945b1a1d..8b52dd56e7 100644 --- a/src/features/browser/ui/SessionBrowserDock.test.ts +++ b/src/features/browser/ui/SessionBrowserDock.test.ts @@ -101,6 +101,30 @@ describe("BrowserDockLayout", () => { 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) diff --git a/src/features/browser/ui/SessionBrowserDock.tsx b/src/features/browser/ui/SessionBrowserDock.tsx index 567b1aa271..ae0a2b739f 100644 --- a/src/features/browser/ui/SessionBrowserDock.tsx +++ b/src/features/browser/ui/SessionBrowserDock.tsx @@ -6,6 +6,7 @@ import { useState, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { DockPanel } from "../../workspace/ui/DockPanel"; import { applyDockGridStyle } from "../../projects/model/projectTerminal"; import { @@ -220,31 +221,36 @@ export function BrowserDockLayout({ > {children}
- {background.length > 0 ? ( -
- {background.map((file) => - file.browser ? ( - - ) : null, - )} -
- ) : null} + {/* 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}
); } From 34fcdb96f932dd1accb2673ebf50f27bae695e6c Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Thu, 8 Oct 2026 10:47:13 -0400 Subject: [PATCH 24/24] Don't require the browser MCP server for Codex With required=true, Codex fails thread startup or resume if the browser server cannot initialize, blocking chats that never use the browser. Tested without it: a new Codex chat uses the browser tools on its first turn, a resumed chat finds them, and chats start normally with Agent browser off. Co-Authored-By: Claude Opus 5.5 --- src/features/browser/model/browserMcp.test.ts | 2 -- src/features/browser/model/browserMcp.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/features/browser/model/browserMcp.test.ts b/src/features/browser/model/browserMcp.test.ts index 5a41a3bcb8..66ff7bd7b5 100644 --- a/src/features/browser/model/browserMcp.test.ts +++ b/src/features/browser/model/browserMcp.test.ts @@ -54,8 +54,6 @@ describe("browser MCP launch", () => { 'mcp_servers.monocode_browser.args=["browser-mcp"]', "-c", 'mcp_servers.monocode_browser.env_vars=["MONOCODE_APP_ENDPOINT","MONOCODE_APP_TOKEN"]', - "-c", - "mcp_servers.monocode_browser.required=true", ]); expect(args.join(" ")).not.toContain("secret"); }); diff --git a/src/features/browser/model/browserMcp.ts b/src/features/browser/model/browserMcp.ts index 15cd911c8a..85bfcb131c 100644 --- a/src/features/browser/model/browserMcp.ts +++ b/src/features/browser/model/browserMcp.ts @@ -62,8 +62,6 @@ export function codexBrowserMcpArgs(launch: BrowserMcpLaunch): string[] { `${key}.args=${JSON.stringify(launch.args)}`, "-c", `${key}.env_vars=${JSON.stringify(CREDENTIAL_ENV)}`, - "-c", - `${key}.required=true`, ]; }