feat: dashboard provider support and session creation improvements (#1092)

Add provider icons and session creation from the dashboard UI.
Sessions can now be created with cloud providers (Browserbase,
Browserless, Browser Use, Kernel) in addition to local engines.

CLI changes:
- Track provider via .provider files alongside .engine files
- Add WaitUntil::None variant to skip lifecycle event waits for providers
- Auto-set waitUntil=none when --provider is used with navigate
- Fix Browser Use: use direct WSS connection (wss://connect.browser-use.com)
- Add connect_cdp_direct for providers with page-level CDP proxies
- Fix resolve_cdp_url to convert https:// provider URLs to wss://
- Treat empty CDP session_id as None (omit from protocol messages)
- Fix Browserbase: send explicit JSON body + Content-Type header
- Increase CDP connect timeout to 25s for remote providers
- Clean up .provider files on session close

Dashboard changes:
- Show provider or engine icon per session in sidebar
- New session dialog with unified engine/provider selector grid
- Async session creation with loading state and error display
- Kill zombie daemons on provider connection failure
- Parse CLI JSON error output for user-friendly messages
- Default new session URL to https://agent-browser.dev
This commit is contained in:
Chris Tate
2026-03-30 18:35:12 -05:00
committed by GitHub
parent f8bc8b368a
commit 40fdb4284d
22 changed files with 274 additions and 107 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agent-browser", "name": "agent-browser",
"description": "Headless browser automation for AI agents", "description": "Browser automation for AI agents",
"owner": { "owner": {
"name": "Vercel", "name": "Vercel",
"email": "support@vercel.com" "email": "support@vercel.com"
+1 -1
View File
@@ -1,6 +1,6 @@
# agent-browser # agent-browser
Headless browser automation CLI for AI agents. Fast native Rust CLI. Browser automation CLI for AI agents. Fast native Rust CLI.
## Installation ## Installation
+3 -1
View File
@@ -112,7 +112,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
format!("https://{}", url) format!("https://{}", url)
}; };
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url }); let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin) if flags.provider.is_some() {
nav_cmd["waitUntil"] = json!("none");
}
if let Some(ref headers_json) = flags.headers { if let Some(ref headers_json) = flags.headers {
let headers = let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| { serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
+24 -3
View File
@@ -1618,8 +1618,13 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
return launch_safari(cmd, state).await; return launch_safari(cmd, state).await;
} }
_ => { _ => {
let (ws_url, provider_session) = providers::connect_provider(provider).await?; let conn = providers::connect_provider(provider).await?;
match BrowserManager::connect_cdp(&ws_url).await { let connect_result = if conn.direct_page {
BrowserManager::connect_cdp_direct(&conn.ws_url).await
} else {
BrowserManager::connect_cdp(&conn.ws_url).await
};
match connect_result {
Ok(mgr) => { Ok(mgr) => {
state.reset_input_state(); state.reset_input_state();
state.browser = Some(mgr); state.browser = Some(mgr);
@@ -1627,10 +1632,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.start_fetch_handler(); state.start_fetch_handler();
state.start_dialog_handler(); state.start_dialog_handler();
state.update_stream_client().await; state.update_stream_client().await;
write_provider_file(&state.session_id, provider);
return Ok(json!({ "launched": true, "provider": provider })); return Ok(json!({ "launched": true, "provider": provider }));
} }
Err(e) => { Err(e) => {
if let Some(ref ps) = provider_session { if let Some(ref ps) = conn.session {
providers::close_provider_session(ps).await; providers::close_provider_session(ps).await;
} }
return Err(e); return Err(e);
@@ -1810,6 +1816,7 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
state.backend_type = BackendType::WebDriver; state.backend_type = BackendType::WebDriver;
state.engine = "safari".to_string(); state.engine = "safari".to_string();
write_engine_file(&state.session_id, &state.engine); write_engine_file(&state.session_id, &state.engine);
write_provider_file(&state.session_id, "ios");
write_extensions_file(&state.session_id); write_extensions_file(&state.session_id);
state.reset_input_state(); state.reset_input_state();
@@ -1857,6 +1864,7 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.backend_type = BackendType::WebDriver; state.backend_type = BackendType::WebDriver;
state.engine = "safari".to_string(); state.engine = "safari".to_string();
write_engine_file(&state.session_id, &state.engine); write_engine_file(&state.session_id, &state.engine);
write_provider_file(&state.session_id, "safari");
write_extensions_file(&state.session_id); write_extensions_file(&state.session_id);
state.reset_input_state(); state.reset_input_state();
@@ -4555,6 +4563,18 @@ fn remove_engine_file(session_id: &str) {
let _ = fs::remove_file(engine_file_path(session_id)); let _ = fs::remove_file(engine_file_path(session_id));
} }
fn provider_file_path(session_id: &str) -> PathBuf {
get_socket_dir().join(format!("{}.provider", session_id))
}
fn write_provider_file(session_id: &str, provider: &str) {
let _ = fs::write(provider_file_path(session_id), provider);
}
fn remove_provider_file(session_id: &str) {
let _ = fs::remove_file(provider_file_path(session_id));
}
fn extensions_file_path(session_id: &str) -> PathBuf { fn extensions_file_path(session_id: &str) -> PathBuf {
get_socket_dir().join(format!("{}.extensions", session_id)) get_socket_dir().join(format!("{}.extensions", session_id))
} }
@@ -4644,6 +4664,7 @@ async fn handle_stream_disable(state: &mut DaemonState) -> Result<Value, String>
state.stream_client = None; state.stream_client = None;
remove_stream_file(&state.session_id)?; remove_stream_file(&state.session_id)?;
remove_engine_file(&state.session_id); remove_engine_file(&state.session_id);
remove_provider_file(&state.session_id);
Ok(json!({ "disabled": true })) Ok(json!({ "disabled": true }))
} }
+56 -4
View File
@@ -149,6 +149,7 @@ pub enum WaitUntil {
Load, Load,
DomContentLoaded, DomContentLoaded,
NetworkIdle, NetworkIdle,
None,
} }
impl WaitUntil { impl WaitUntil {
@@ -156,6 +157,7 @@ impl WaitUntil {
match s { match s {
"domcontentloaded" => Self::DomContentLoaded, "domcontentloaded" => Self::DomContentLoaded,
"networkidle" => Self::NetworkIdle, "networkidle" => Self::NetworkIdle,
"none" => Self::None,
_ => Self::Load, _ => Self::Load,
} }
} }
@@ -326,6 +328,16 @@ impl BrowserManager {
} }
pub async fn connect_cdp(url: &str) -> Result<Self, String> { pub async fn connect_cdp(url: &str) -> Result<Self, String> {
Self::connect_cdp_inner(url, false).await
}
/// Connect to a provider CDP proxy where the WebSocket IS the page session.
/// Skips browser-level Target.* commands that most proxies don't support.
pub async fn connect_cdp_direct(url: &str) -> Result<Self, String> {
Self::connect_cdp_inner(url, true).await
}
async fn connect_cdp_inner(url: &str, direct_page: bool) -> Result<Self, String> {
let ws_url = resolve_cdp_url(url).await?; let ws_url = resolve_cdp_url(url).await?;
let client = Arc::new(CdpClient::connect(&ws_url).await?); let client = Arc::new(CdpClient::connect(&ws_url).await?);
let mut manager = Self { let mut manager = Self {
@@ -334,12 +346,24 @@ impl BrowserManager {
ws_url, ws_url,
pages: Vec::new(), pages: Vec::new(),
active_page_index: 0, active_page_index: 0,
default_timeout_ms: 10_000, default_timeout_ms: 25_000,
download_path: None, // CDP connections don't have a launch-time download path download_path: None,
visited_origins: HashSet::new(), visited_origins: HashSet::new(),
}; };
manager.discover_and_attach_targets().await?; if direct_page {
manager.pages.push(PageInfo {
target_id: "provider-page".to_string(),
session_id: String::new(),
url: String::new(),
title: String::new(),
target_type: "page".to_string(),
});
manager.active_page_index = 0;
manager.enable_domains_direct().await?;
} else {
manager.discover_and_attach_targets().await?;
}
Ok(manager) Ok(manager)
} }
@@ -465,6 +489,20 @@ impl BrowserManager {
Ok(()) Ok(())
} }
/// Enable domains on a direct page connection (no session_id needed).
async fn enable_domains_direct(&self) -> Result<(), String> {
self.client
.send_command_no_params("Page.enable", None)
.await?;
self.client
.send_command_no_params("Runtime.enable", None)
.await?;
self.client
.send_command_no_params("Network.enable", None)
.await?;
Ok(())
}
pub fn active_session_id(&self) -> Result<&str, String> { pub fn active_session_id(&self) -> Result<&str, String> {
self.pages self.pages
.get(self.active_page_index) .get(self.active_page_index)
@@ -495,7 +533,7 @@ impl BrowserManager {
// Only wait for lifecycle events if Chrome created a new loader (full navigation). // Only wait for lifecycle events if Chrome created a new loader (full navigation).
// If loader_id is None, it was a same-document navigation (e.g., hash routing) // If loader_id is None, it was a same-document navigation (e.g., hash routing)
// which does not fire Page.loadEventFired or Page.domContentEventFired. // which does not fire Page.loadEventFired or Page.domContentEventFired.
if nav_result.loader_id.is_some() { if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx) self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await?; .await?;
} }
@@ -529,6 +567,7 @@ impl BrowserManager {
WaitUntil::Load => "Page.loadEventFired", WaitUntil::Load => "Page.loadEventFired",
WaitUntil::DomContentLoaded => "Page.domContentEventFired", WaitUntil::DomContentLoaded => "Page.domContentEventFired",
WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await, WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await,
WaitUntil::None => return Ok(()),
}; };
let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
@@ -1366,6 +1405,19 @@ async fn resolve_cdp_url(input: &str) -> Result<String, String> {
if input.starts_with("http://") || input.starts_with("https://") { if input.starts_with("http://") || input.starts_with("https://") {
let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?; let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?;
// If no explicit port and path is empty/root, this is likely a provider
// WebSocket endpoint (e.g. https://xxx.cdp0.browser-use.com). Convert
// the scheme to ws/wss and connect directly instead of probing :9222.
if parsed.port().is_none() && (parsed.path().is_empty() || parsed.path() == "/") {
let ws_scheme = if input.starts_with("https://") {
"wss"
} else {
"ws"
};
let mut ws_url = parsed.clone();
let _ = ws_url.set_scheme(ws_scheme);
return Ok(ws_url.to_string());
}
let host = parsed let host = parsed
.host_str() .host_str()
.ok_or_else(|| format!("No host in CDP URL: {}", input))?; .ok_or_else(|| format!("No host in CDP URL: {}", input))?;
+1 -1
View File
@@ -199,7 +199,7 @@ impl CdpClient {
id, id,
method: method.to_string(), method: method.to_string(),
params, params,
session_id: session_id.map(|s| s.to_string()), session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
}; };
let json = serde_json::to_string(&cmd) let json = serde_json::to_string(&cmd)
+2
View File
@@ -63,6 +63,7 @@ pub async fn run_daemon(session: &str) {
let stream_path = socket_dir.join(format!("{}.stream", session)); let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path); let _ = fs::remove_file(&stream_path);
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") { if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
@@ -119,6 +120,7 @@ pub async fn run_daemon(session: &str) {
let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&stream_path); let _ = fs::remove_file(&stream_path);
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
if let Err(e) = result { if let Err(e) = result {
+45 -54
View File
@@ -12,16 +12,49 @@ pub struct ProviderSession {
pub session_id: String, pub session_id: String,
} }
pub struct ProviderConnection {
pub ws_url: String,
pub session: Option<ProviderSession>,
/// If true, the WebSocket IS the page session (no Target.* commands).
pub direct_page: bool,
}
/// Connects to the specified browser provider and returns a CDP WebSocket URL /// Connects to the specified browser provider and returns a CDP WebSocket URL
/// along with session info for cleanup on failure. /// along with session info for cleanup on failure.
pub async fn connect_provider( pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
provider_name: &str,
) -> Result<(String, Option<ProviderSession>), String> {
match provider_name.to_lowercase().as_str() { match provider_name.to_lowercase().as_str() {
"browserbase" => connect_browserbase().await, "browserbase" => {
"browserless" => connect_browserless().await, let (url, session) = connect_browserbase().await?;
"browser-use" | "browseruse" => connect_browser_use().await, Ok(ProviderConnection {
"kernel" => connect_kernel().await, ws_url: url,
session,
direct_page: false,
})
}
"browserless" => {
let (url, session) = connect_browserless().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"browser-use" | "browseruse" => {
let (url, session) = connect_browser_use().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
"kernel" => {
let (url, session) = connect_kernel().await?;
Ok(ProviderConnection {
ws_url: url,
session,
direct_page: false,
})
}
_ => Err(format!( _ => Err(format!(
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel", "Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel",
provider_name provider_name
@@ -91,7 +124,9 @@ async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), Stri
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let response = client let response = client
.post("https://api.browserbase.com/v1/sessions") .post("https://api.browserbase.com/v1/sessions")
.header("X-BB-API-Key", &api_key) .header("content-type", "application/json")
.header("x-bb-api-key", &api_key)
.body("{}")
.send() .send()
.await .await
.map_err(|e| format!("Browserbase request failed: {}", e))?; .map_err(|e| format!("Browserbase request failed: {}", e))?;
@@ -219,53 +254,9 @@ async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), Stri
let api_key = env::var("BROWSER_USE_API_KEY") let api_key = env::var("BROWSER_USE_API_KEY")
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?; .map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
let client = reqwest::Client::new(); let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
let response = client
.post("https://api.browser-use.com/api/v2/browsers")
.header("Content-Type", "application/json")
.header("X-Browser-Use-API-Key", &api_key)
.json(&json!({}))
.send()
.await
.map_err(|e| format!("Browser Use request failed: {}", e))?;
let status = response.status(); Ok((ws_url, None))
let body = response
.text()
.await
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
if !status.is_success() {
return Err(format!(
"Browser Use API error ({}): {}",
status.as_u16(),
body
));
}
let json: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
let session_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ws_url = json
.get("cdp_url")
.or_else(|| json.get("cdpUrl"))
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
Ok((
ws_url,
Some(ProviderSession {
provider: "browser-use".to_string(),
session_id,
}),
))
} }
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> { async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
+8
View File
@@ -1322,6 +1322,11 @@ fn discover_sessions() -> String {
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "chrome".to_string()); .unwrap_or_else(|| "chrome".to_string());
let provider_path = dir.join(format!("{}.provider", session));
let provider = std::fs::read_to_string(&provider_path)
.ok()
.filter(|s| !s.trim().is_empty());
let extensions = read_extensions_metadata(&dir, session); let extensions = read_extensions_metadata(&dir, session);
let mut entry = json!({ let mut entry = json!({
@@ -1329,6 +1334,9 @@ fn discover_sessions() -> String {
"port": port, "port": port,
"engine": engine.trim(), "engine": engine.trim(),
}); });
if let Some(ref p) = provider {
entry["provider"] = json!(p.trim());
}
if !extensions.is_empty() { if !extensions.is_empty() {
entry["extensions"] = json!(extensions); entry["extensions"] = json!(extensions);
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents. const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a browser automation CLI designed for AI agents.
GitHub repository: https://github.com/vercel-labs/agent-browser GitHub repository: https://github.com/vercel-labs/agent-browser
Documentation: https://agent-browser.dev Documentation: https://agent-browser.dev
+6 -6
View File
@@ -25,23 +25,23 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://agent-browser.dev"), metadataBase: new URL("https://agent-browser.dev"),
title: { title: {
default: "agent-browser | Headless Browser Automation for AI", default: "agent-browser | Browser Automation for AI",
template: "%s | agent-browser", template: "%s | agent-browser",
}, },
description: "Headless browser automation CLI for AI agents", description: "Browser automation CLI for AI agents",
openGraph: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
url: "https://agent-browser.dev", url: "https://agent-browser.dev",
siteName: "agent-browser", siteName: "agent-browser",
title: "agent-browser | Headless Browser Automation for AI", title: "agent-browser | Browser Automation for AI",
description: "Headless browser automation CLI for AI agents", description: "Browser automation CLI for AI agents",
images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }], images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "agent-browser | Headless Browser Automation for AI", title: "agent-browser | Browser Automation for AI",
description: "Headless browser automation CLI for AI agents", description: "Browser automation CLI for AI agents",
images: ["/og"], images: ["/og"],
}, },
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { PAGE_TITLES } from "./page-titles"; import { PAGE_TITLES } from "./page-titles";
const DESCRIPTION = const DESCRIPTION =
"Headless browser automation CLI for AI agents"; "Browser automation CLI for AI agents";
export function pageMetadata(slug: string): Metadata { export function pageMetadata(slug: string): Metadata {
const title = PAGE_TITLES[slug]; const title = PAGE_TITLES[slug];
+1 -1
View File
@@ -1,5 +1,5 @@
export const PAGE_TITLES: Record<string, string> = { export const PAGE_TITLES: Record<string, string> = {
"": "Headless Browser\nAutomation for AI", "": "Browser\nAutomation for AI",
installation: "Installation", installation: "Installation",
"quick-start": "Quick Start", "quick-start": "Quick Start",
skills: "Skills", skills: "Skills",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "agent-browser", "name": "agent-browser",
"version": "0.23.1", "version": "0.23.1",
"description": "Headless browser automation CLI for AI agents", "description": "Browser automation CLI for AI agents",
"type": "module", "type": "module",
"files": [ "files": [
"bin", "bin",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.111 0 100 100" fill="none"><rect x="-0.111" y="0" width="100" height="100" fill="#F03603"/><path fill-rule="evenodd" clip-rule="evenodd" d="M35.9167 27.7778H51.1548C57.504 27.7778 62.5833 32.8571 62.5833 39.2063V41.746C62.5833 44.6667 61.5039 47.3968 59.6627 49.3651C62.2023 51.4603 63.8531 54.6349 63.8531 58.254V60.7937C63.8531 67.1429 58.7738 72.2222 52.4246 72.2222H35.9167V27.7778ZM42.2659 46.8254H51.1548C54.0119 46.8254 56.2342 44.6032 56.2342 41.746V39.2063C56.2342 36.3492 54.0119 34.127 51.1548 34.127H42.2659V46.8254ZM42.2659 53.1746V65.873H52.4246C55.2818 65.873 57.504 63.6508 57.504 60.7937V58.254C57.504 55.3968 55.2818 53.1746 52.4246 53.1746H42.2659Z" fill="white"/></svg>

After

Width:  |  Height:  |  Size: 742 B

@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-8.352 0.04 34.863 34.863" fill="CurrentColor" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_270_330)" fill="#E2E8F0"><path d="M11.6737 15.6821 V 6.95583 L 7.76842 4.65057 V 24.04 L 14.2421 21.0085 L 14.2526 25.2506 L 3.92632 30.3874 V 2.32425 L 0.0421053 0.0400391 L 0 32.3874 L 3.74737 34.9032 L 18.1579 27.7453 L 18.1368 19.4927 L 11.6737 15.6821 Z" fill="#E2E8F0"/></g><defs><clipPath id="clip0_270_330"><rect width="155.789" height="40" fill="white" transform="translate(0 0.0400391)"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 586 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -52,24 +52,40 @@ const ENGINE_LOGOS: Record<string, string> = {
lightpanda: "/lightpanda.svg", lightpanda: "/lightpanda.svg",
}; };
const PROVIDER_LOGOS: Record<string, string> = {
browserbase: "/providers/browserbase.svg",
browserless: "/providers/browserless.svg",
"browser-use": "/providers/browser-use.svg",
kernel: "/providers/kernel.svg",
};
const SUPPORTED_ENGINES = ["chrome", "lightpanda"] as const; const SUPPORTED_ENGINES = ["chrome", "lightpanda"] as const;
function EngineLogo({ engine }: { engine: string }) { const BROWSER_OPTIONS: { id: string; label: string; engine?: string; provider?: string }[] = [
const src = ENGINE_LOGOS[engine]; { id: "chrome", label: "Chrome", engine: "chrome" },
{ id: "lightpanda", label: "Lightpanda", engine: "lightpanda" },
{ id: "browserbase", label: "Browserbase", provider: "browserbase" },
{ id: "browserless", label: "Browserless", provider: "browserless" },
{ id: "browser-use", label: "Browser Use", provider: "browser-use" },
{ id: "kernel", label: "Kernel", provider: "kernel" },
];
function BrandLogo({ name, logos }: { name: string; logos: Record<string, string> }) {
const src = logos[name];
if (!src) { if (!src) {
if (!engine) { if (!name) {
return <span className="size-4 shrink-0" />; return <span className="size-4 shrink-0" />;
} }
return ( return (
<span className="flex size-4 shrink-0 items-center justify-center rounded bg-muted text-[8px] font-bold text-muted-foreground uppercase"> <span className="flex size-4 shrink-0 items-center justify-center rounded bg-muted text-[8px] font-bold text-muted-foreground uppercase">
{engine.charAt(0)} {name.charAt(0)}
</span> </span>
); );
} }
return ( return (
<img <img
src={src} src={src}
alt={engine} alt={name}
width={16} width={16}
height={16} height={16}
className="size-4 shrink-0" className="size-4 shrink-0"
@@ -77,6 +93,14 @@ function EngineLogo({ engine }: { engine: string }) {
); );
} }
function EngineLogo({ engine }: { engine: string }) {
return <BrandLogo name={engine} logos={ENGINE_LOGOS} />;
}
function ProviderLogo({ provider }: { provider: string }) {
return <BrandLogo name={provider} logos={PROVIDER_LOGOS} />;
}
function getFaviconUrl(url: string): string | null { function getFaviconUrl(url: string): string | null {
try { try {
const { hostname } = new URL(url); const { hostname } = new URL(url);
@@ -152,6 +176,7 @@ function SessionNode({
isActive, isActive,
tabs, tabs,
engine, engine,
provider,
expanded, expanded,
onSelect, onSelect,
onToggle, onToggle,
@@ -165,6 +190,7 @@ function SessionNode({
isActive: boolean; isActive: boolean;
tabs: TabInfo[]; tabs: TabInfo[];
engine: string; engine: string;
provider: string;
expanded: boolean; expanded: boolean;
onSelect: () => void; onSelect: () => void;
onToggle: () => void; onToggle: () => void;
@@ -184,7 +210,9 @@ function SessionNode({
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
</span> </span>
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1"> <span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1">
<EngineLogo engine={session.engine ?? engine} /> {(session.provider ?? provider)
? <ProviderLogo provider={session.provider ?? provider} />
: <EngineLogo engine={session.engine ?? engine} />}
<span className="truncate font-mono font-semibold"> <span className="truncate font-mono font-semibold">
{session.session} {session.session}
</span> </span>
@@ -212,7 +240,9 @@ function SessionNode({
<ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} /> <ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
</span> </span>
<span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1 text-left"> <span className="flex flex-1 min-w-0 items-center gap-2 py-1.5 pr-3 pl-1 text-left">
<EngineLogo engine={engine} /> {provider
? <ProviderLogo provider={provider} />
: <EngineLogo engine={engine} />}
<span className="truncate font-mono font-semibold"> <span className="truncate font-mono font-semibold">
{session.session} {session.session}
</span> </span>
@@ -318,7 +348,9 @@ export function SessionTree() {
const [newSessionOpen, setNewSessionOpen] = useState(false); const [newSessionOpen, setNewSessionOpen] = useState(false);
const [closeAllOpen, setCloseAllOpen] = useState(false); const [closeAllOpen, setCloseAllOpen] = useState(false);
const [newSessionName, setNewSessionName] = useState(""); const [newSessionName, setNewSessionName] = useState("");
const [newSessionEngine, setNewSessionEngine] = useState("chrome"); const [newSessionBrowser, setNewSessionBrowser] = useState("chrome");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
const isExpanded = useCallback( const isExpanded = useCallback(
@@ -330,14 +362,25 @@ export function SessionTree() {
setExpandedMap((prev) => ({ ...prev, [port]: !(prev[port] ?? true) })); setExpandedMap((prev) => ({ ...prev, [port]: !(prev[port] ?? true) }));
}, []); }, []);
const handleCreateSubmit = useCallback(() => { const handleCreateSubmit = useCallback(async () => {
const name = newSessionName.trim(); const name = newSessionName.trim();
if (name) { if (!name || creating) return;
dispatchCreateSession({ name, engine: newSessionEngine }); setCreating(true);
setCreateError("");
const option = BROWSER_OPTIONS.find((o) => o.id === newSessionBrowser);
const error = await dispatchCreateSession({
name,
engine: option?.engine ?? "chrome",
provider: option?.provider,
});
setCreating(false);
if (error) {
setCreateError(error);
} else {
setNewSessionName(""); setNewSessionName("");
setNewSessionOpen(false); setNewSessionOpen(false);
} }
}, [newSessionName, newSessionEngine, dispatchCreateSession]); }, [newSessionName, newSessionBrowser, creating, dispatchCreateSession]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -379,6 +422,7 @@ export function SessionTree() {
isActive={s.port === activePort} isActive={s.port === activePort}
tabs={getTabsForSession(s.port)} tabs={getTabsForSession(s.port)}
engine={getEngineForSession(s.port)} engine={getEngineForSession(s.port)}
provider={s.provider ?? ""}
expanded={isExpanded(s.port)} expanded={isExpanded(s.port)}
onSelect={() => setActivePort(s.port)} onSelect={() => setActivePort(s.port)}
onToggle={() => toggleExpanded(s.port)} onToggle={() => toggleExpanded(s.port)}
@@ -393,8 +437,11 @@ export function SessionTree() {
</div> </div>
</ScrollArea> </ScrollArea>
<Dialog open={newSessionOpen} onOpenChange={setNewSessionOpen}> <Dialog open={newSessionOpen} onOpenChange={(open) => {
<DialogContent className="sm:max-w-sm"> setNewSessionOpen(open);
if (open) setCreateError("");
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>New session</DialogTitle> <DialogTitle>New session</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -411,40 +458,52 @@ export function SessionTree() {
}} }}
placeholder="Session name" placeholder="Session name"
autoFocus autoFocus
disabled={creating}
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/> />
<div className="flex gap-2"> <div className="grid grid-cols-3 gap-2">
{SUPPORTED_ENGINES.map((eng) => ( {BROWSER_OPTIONS.map((opt) => (
<button <button
key={eng} key={opt.id}
type="button" type="button"
onClick={() => setNewSessionEngine(eng)} disabled={creating}
onClick={() => setNewSessionBrowser(opt.id)}
className={cn( className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors", "flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
newSessionEngine === eng newSessionBrowser === opt.id
? "border-ring bg-muted text-foreground" ? "border-ring bg-muted text-foreground"
: "border-border text-muted-foreground hover:text-foreground", : "border-border text-muted-foreground hover:text-foreground",
creating && "opacity-50",
)} )}
> >
<EngineLogo engine={eng} /> {opt.engine
{eng} ? <EngineLogo engine={opt.engine} />
: <ProviderLogo provider={opt.provider!} />}
{opt.label}
</button> </button>
))} ))}
</div> </div>
{createError && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{createError}
</p>
)}
<DialogFooter> <DialogFooter>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setNewSessionOpen(false)} onClick={() => setNewSessionOpen(false)}
disabled={creating}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={handleCreateSubmit} onClick={handleCreateSubmit}
disabled={!newSessionName.trim()} disabled={!newSessionName.trim() || creating}
> >
Create {creating && <Loader2 className="size-3 animate-spin" />}
{creating ? "Creating..." : "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+34 -7
View File
@@ -4,7 +4,7 @@ import { atom } from "jotai";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useAtomCallback } from "jotai/utils"; import { useAtomCallback } from "jotai/utils";
import type { SessionInfo } from "@/types"; import type { SessionInfo } from "@/types";
import { execCommand, killSession, sessionArgs } from "@/lib/exec"; import { type ExecResult, execCommand, killSession, sessionArgs } from "@/lib/exec";
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs"; import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
import { streamTabsAtom, streamEngineAtom } from "@/store/stream"; import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
@@ -35,7 +35,7 @@ export const activePortAtom = atom(getPort());
export const polledSessionsAtom = atom<SessionInfo[]>([]); export const polledSessionsAtom = atom<SessionInfo[]>([]);
export const pendingSessionsAtom = atom<{ session: string; engine: string }[]>( export const pendingSessionsAtom = atom<{ session: string; engine: string; provider?: string }[]>(
[], [],
); );
@@ -57,6 +57,7 @@ export const sessionsAtom = atom((get) => {
session: p.session, session: p.session,
port: 0, port: 0,
engine: p.engine, engine: p.engine,
provider: p.provider,
pending: true as const, pending: true as const,
})); }));
const merged = polled.map((s) => const merged = polled.map((s) =>
@@ -88,16 +89,42 @@ export const activeExtensionsAtom = atom((get) => {
export const createSessionAtom = atom( export const createSessionAtom = atom(
null, null,
( async (
_get, _get,
set, set,
{ name, engine }: { name: string; engine: string }, { name, engine, provider }: { name: string; engine: string; provider?: string },
) => { ): Promise<string | null> => {
set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine }]); set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine, provider }]);
execCommand(["--session", name, "--engine", engine, "open", "about:blank"]); const args = ["--session", name];
if (provider) {
args.push("--provider", provider);
} else {
args.push("--engine", engine);
}
args.push("open", "https://agent-browser.dev");
const result = await execCommand(args);
if (!result.success) {
set(pendingSessionsAtom, (prev) => prev.filter((p) => p.session !== name));
killSession(name);
return parseExecError(result) || "Failed to create session";
}
return null;
}, },
); );
function parseExecError(result: ExecResult): string {
if (result.stderr) return result.stderr;
if (result.stdout) {
try {
const json = JSON.parse(result.stdout);
if (json.error) return json.error;
} catch {
// stdout wasn't JSON
}
}
return "";
}
export const closeSessionAtom = atom(null, (get, set, port: number) => { export const closeSessionAtom = atom(null, (get, set, port: number) => {
const sessions = get(sessionsAtom); const sessions = get(sessionsAtom);
const s = sessions.find((x) => x.port === port)?.session; const s = sessions.find((x) => x.port === port)?.session;
+1
View File
@@ -105,6 +105,7 @@ export interface SessionInfo {
session: string; session: string;
port: number; port: number;
engine?: string; engine?: string;
provider?: string;
extensions?: ExtensionInfo[]; extensions?: ExtensionInfo[];
pending?: boolean; pending?: boolean;
closing?: boolean; closing?: boolean;