From 8ca16401b11ce45816a85f8daf71d5888cc95641 Mon Sep 17 00:00:00 2001 From: hellovai Date: Fri, 10 Jul 2026 12:31:51 -0700 Subject: [PATCH 1/2] fix(playground): reuse the browser window on OpenPlayground (B-808) In `baml playground` (browser) mode the LSP is served over the /api/lsp WebSocket, so an editor's inline "Open Playground" lens reaches the server as an OpenPlayground request. The native sender unconditionally called `webbrowser::open()` and returned early, so every inline click spawned a fresh browser window at the root URL, losing the function/test target. Browser-mode OpenPlayground now: - broadcasts over the WS so an already-open page navigates in place; - only opens a browser window when no page is connected (`receiver_count() == 0`), debounced so two quick clicks before the first window connects don't double-open; - records the target and replays it to a page on RequestState, so a freshly opened or reconnected window lands on the right function/test. Editor (VS Code) mode is unchanged: the LSP-notification path stays, and the shared target stays None so the WS replay is a no-op there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/baml_lsp_server/src/lib.rs | 9 ++ .../baml_lsp_server/src/playground_sender.rs | 103 +++++++++++++++--- .../baml_lsp_server/src/playground_server.rs | 26 +++++ 3 files changed, 122 insertions(+), 16 deletions(-) diff --git a/baml_language/crates/baml_lsp_server/src/lib.rs b/baml_language/crates/baml_lsp_server/src/lib.rs index a318b93da65..1c9ef8bd59c 100644 --- a/baml_language/crates/baml_lsp_server/src/lib.rs +++ b/baml_language/crates/baml_lsp_server/src/lib.rs @@ -264,6 +264,12 @@ fn run_server_inner( print_playground_banner(playground_port, &workspace_roots, session_token.as_deref()); } + // Tracks the target of the most recent OpenPlayground so browser-mode pages + // that connect after the request can be navigated to it (see the sender and + // the WS `RequestState` handler). Shared between the sender and the server. + let current_open_target: playground_sender::SharedOpenTarget = + Arc::new(std::sync::Mutex::new(None)); + // Playground sender (needs port + lsp_sender for OpenPlayground) let playground_sender: Arc = Arc::new(playground_sender::NativePlaygroundSender::new( @@ -272,6 +278,7 @@ fn run_server_inner( playground_port, matches!(playground_open_target, PlaygroundOpenTarget::Browser), session_token.clone(), + current_open_target.clone(), )); // Create the BexLsp (multi-project LSP) @@ -363,6 +370,7 @@ fn run_server_inner( doc_mirror, workspace_roots.clone(), session_token, + current_open_target.clone(), )); } @@ -380,6 +388,7 @@ fn run_server_inner( doc_mirror, workspace_roots_for_server, session_token, + current_open_target.clone(), ) .await { diff --git a/baml_language/crates/baml_lsp_server/src/playground_sender.rs b/baml_language/crates/baml_lsp_server/src/playground_sender.rs index c3148c21e26..56ea7f4857f 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_sender.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_sender.rs @@ -4,16 +4,42 @@ //! playground notifications through a `tokio::sync::broadcast` channel //! that WebSocket clients subscribe to. //! -//! `OpenPlayground` is special: instead of going over WebSocket it either -//! opens the system browser for `baml playground` or sends an LSP -//! notification to the editor client. +//! `OpenPlayground` is special. In editor mode it sends an LSP notification to +//! the editor client. In browser mode (`baml playground`) it navigates the +//! open playground page over the WebSocket and only spawns a system browser +//! window when no page is connected, so repeated opens reuse one window +//! instead of stacking new ones. -use std::sync::Arc; +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; use tokio::sync::broadcast; use crate::playground_ws::WsOutMessage; +/// The playground target most recently requested via `OpenPlayground`. +/// +/// Browser mode records this so a page that connects *after* the request +/// (a freshly spawned window, or a reconnect) can be navigated to the same +/// target when it asks for state. See the `RequestState` handler. +#[derive(Clone, Default)] +pub struct OpenPlaygroundTarget { + pub project: String, + pub function_name: Option, + pub test_name: Option, + pub testset_name: Option, +} + +/// Shared between the sender (writer) and the WS server (replays it on +/// connect). `None` until the first `OpenPlayground` in a browser session. +pub type SharedOpenTarget = Arc>>; + +/// A second browser window must not be spawned while the first is still +/// loading (before it connects and bumps `receiver_count`). +const BROWSER_SPAWN_DEBOUNCE: Duration = Duration::from_secs(5); + pub struct NativePlaygroundSender { broadcast_tx: broadcast::Sender, lsp_sender: Arc, @@ -22,6 +48,10 @@ pub struct NativePlaygroundSender { /// Browser-mode session token; carried on the opened URL so the page can /// authorize its /api requests. session_token: Option>, + /// Last requested open target, shared with the WS server for replay. + current_open_target: SharedOpenTarget, + /// When we last spawned a browser window, to debounce double-opens. + last_browser_open: Mutex>, } impl NativePlaygroundSender { @@ -31,6 +61,7 @@ impl NativePlaygroundSender { playground_port: u16, open_in_browser: bool, session_token: Option>, + current_open_target: SharedOpenTarget, ) -> Self { Self { broadcast_tx, @@ -38,8 +69,24 @@ impl NativePlaygroundSender { playground_port, open_in_browser, session_token, + current_open_target, + last_browser_open: Mutex::new(None), } } + + /// Returns true if this call should spawn a browser window. Debounces + /// rapid `OpenPlayground`s that arrive before the first window connects. + fn claim_browser_open(&self) -> bool { + let mut guard = self.last_browser_open.lock().unwrap(); + let now = Instant::now(); + if let Some(prev) = *guard + && now.duration_since(prev) < BROWSER_SPAWN_DEBOUNCE + { + return false; + } + *guard = Some(now); + true + } } impl bex_project::PlaygroundSender for NativePlaygroundSender { @@ -52,19 +99,43 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender { } = notification { if self.open_in_browser { - let url = match &self.session_token { - Some(token) => { - format!("http://localhost:{}/?token={token}", self.playground_port) - } - None => format!("http://localhost:{}", self.playground_port), - }; - // `webbrowser::open` can block until a text-mode browser (lynx/w3m) - // exits on headless hosts; never hold up server startup on it. - std::thread::spawn(move || { - if let Err(e) = webbrowser::open(&url) { - tracing::error!("Failed to open browser at {}: {}", url, e); - } + // Remember where to navigate so a page that connects after this + // request (fresh window or reconnect) can be sent here on + // `RequestState`. + *self.current_open_target.lock().unwrap() = Some(OpenPlaygroundTarget { + project: project.clone(), + function_name: function_name.clone(), + test_name: test_name.clone(), + testset_name: testset_name.clone(), }); + + // Navigate any already-open page in place rather than spawning a + // new window: pages listen on this broadcast and react to the + // `openPlayground` notification. + let json = serde_json::to_value(¬ification).unwrap_or_default(); + let _ = self + .broadcast_tx + .send(WsOutMessage::PlaygroundNotification { notification: json }); + + // Only open a browser window when nothing is connected. The + // debounce avoids a second window while the first is still + // loading (before it connects and bumps `receiver_count`). + if self.broadcast_tx.receiver_count() == 0 && self.claim_browser_open() { + let url = match &self.session_token { + Some(token) => { + format!("http://localhost:{}/?token={token}", self.playground_port) + } + None => format!("http://localhost:{}", self.playground_port), + }; + // `webbrowser::open` can block until a text-mode browser + // (lynx/w3m) exits on headless hosts; never hold up the + // server on it. + std::thread::spawn(move || { + if let Err(e) = webbrowser::open(&url) { + tracing::error!("Failed to open browser at {}: {}", url, e); + } + }); + } } else { let params = serde_json::json!({ "port": self.playground_port, diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index d2102bc8116..71ff0562eda 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -495,6 +495,9 @@ struct WsState { doc_mirror: DocMirror, /// Workspace roots that browser-mode LSP saves are allowed to write under. workspace_roots: Arc>, + /// Target of the most recent OpenPlayground; replayed to a page when it + /// requests state so a freshly opened / reconnected window navigates there. + current_open_target: crate::playground_sender::SharedOpenTarget, } type LiveValueStore = Arc>; @@ -969,6 +972,7 @@ pub async fn run( doc_mirror: DocMirror, workspace_roots: Vec, session_token: Option>, + current_open_target: crate::playground_sender::SharedOpenTarget, ) -> anyhow::Result<()> { let local_addr = listener.local_addr()?; let access_guard = PlaygroundAccessGuard::new(session_token); @@ -983,6 +987,7 @@ pub async fn run( doc_mirror, Arc::new(workspace_roots), access_guard, + current_open_target, )?; tracing::info!("Playground: http://localhost:{}", local_addr.port()); @@ -1004,6 +1009,7 @@ fn build_router( doc_mirror: DocMirror, workspace_roots: Arc>, access_guard: PlaygroundAccessGuard, + current_open_target: crate::playground_sender::SharedOpenTarget, ) -> anyhow::Result { let value_store = Arc::new(Mutex::new(LiveValueCache::with_max_bytes( DEFAULT_NATIVE_LIVE_VALUE_CACHE_BYTES, @@ -1022,6 +1028,7 @@ fn build_router( lsp_out_tx, doc_mirror, workspace_roots, + current_open_target, }; let api = Router::new() @@ -2365,6 +2372,25 @@ async fn handle_ws_in_message( WsInMessage::RequestState => { state.bex.request_playground_state(); + // A page that connected after an OpenPlayground request (a freshly + // spawned browser window, or a reconnect) still needs to be told + // where to navigate. Replay the last target directly to it. + let target = state.current_open_target.lock().unwrap().clone(); + if let Some(target) = target { + let notif = bex_project::PlaygroundNotification::OpenPlayground { + project: target.project, + function_name: target.function_name, + test_name: target.test_name, + testset_name: target.testset_name, + }; + let json = serde_json::to_value(¬if).unwrap_or_default(); + let msg = WsOutMessage::PlaygroundNotification { notification: json }; + if let Some(ws_msg) = to_ws_text(&msg) + && sink.send(ws_msg).await.is_err() + { + tracing::warn!("Failed to replay open-playground target"); + } + } } WsInMessage::RequestCollectTests { project } => { From 70e890dc4bdc37f1babd0dddc80b72b1a90b43a4 Mon Sep 17 00:00:00 2001 From: hellovai Date: Fri, 10 Jul 2026 12:38:29 -0700 Subject: [PATCH 2/2] test(playground): cover browser-mode OpenPlayground reuse - browser-mode OpenPlayground broadcasts the openPlayground notification in place (correctly serialized for the page) and records the target for RequestState replay; - claim_browser_open debounces rapid spawns so two quick opens before the first window connects don't stack windows. Holding a live broadcast receiver keeps receiver_count() > 0, which exercises the reuse path and guarantees the test never launches a real browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../baml_lsp_server/src/playground_sender.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/baml_language/crates/baml_lsp_server/src/playground_sender.rs b/baml_language/crates/baml_lsp_server/src/playground_sender.rs index 56ea7f4857f..61048dd70f8 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_sender.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_sender.rs @@ -169,3 +169,89 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender { .send(WsOutMessage::PlaygroundNotification { notification: json }); } } + +#[cfg(test)] +mod tests { + use bex_project::PlaygroundSender; + + use super::*; + + struct NoopLspSender; + impl bex_project::LspClientSenderTrait for NoopLspSender { + fn send_notification( + &self, + _: lsp_server::Notification, + ) -> Result<(), bex_project::LspError> { + Ok(()) + } + fn send_response_impl(&self, _: lsp_server::Response) -> Result<(), bex_project::LspError> { + Ok(()) + } + fn make_request(&self, _: lsp_server::Request) -> Result<(), bex_project::LspError> { + Ok(()) + } + } + + fn browser_sender( + broadcast_tx: broadcast::Sender, + target: SharedOpenTarget, + ) -> NativePlaygroundSender { + NativePlaygroundSender::new( + broadcast_tx, + Arc::new(NoopLspSender), + 4265, + true, // browser mode + None, + target, + ) + } + + /// Browser-mode OpenPlayground navigates the already-open page in place and + /// records the target for replay — the core of B-808. Holding a live + /// receiver keeps `receiver_count() > 0`, which both exercises the reuse + /// path and guarantees the test never launches a real browser. + #[test] + fn browser_open_playground_broadcasts_in_place_and_records_target() { + let (tx, mut rx) = broadcast::channel(8); + let target: SharedOpenTarget = Arc::new(Mutex::new(None)); + let sender = browser_sender(tx, target.clone()); + + sender.send_playground_notification(bex_project::PlaygroundNotification::OpenPlayground { + project: "/tmp/proj".to_string(), + function_name: Some("Foo".to_string()), + test_name: None, + testset_name: None, + }); + + let WsOutMessage::PlaygroundNotification { notification } = rx + .try_recv() + .expect("open should broadcast to the connected page") + else { + panic!("expected a playground notification"); + }; + assert_eq!(notification["type"], "openPlayground"); + assert_eq!(notification["project"], "/tmp/proj"); + assert_eq!(notification["functionName"], "Foo"); + + let recorded = target + .lock() + .unwrap() + .clone() + .expect("target recorded for RequestState replay"); + assert_eq!(recorded.project, "/tmp/proj"); + assert_eq!(recorded.function_name.as_deref(), Some("Foo")); + } + + /// Two OpenPlaygrounds arriving before the first window connects must not + /// spawn two browser windows. + #[test] + fn claim_browser_open_debounces_rapid_spawns() { + let (tx, _rx) = broadcast::channel(8); + let sender = browser_sender(tx, Arc::new(Mutex::new(None))); + assert!(sender.claim_browser_open(), "first spawn allowed"); + assert!( + !sender.claim_browser_open(), + "second spawn within debounce suppressed" + ); + } +}