From 40fdb4284d519dfcf0abdc84b910a9ed7754ab7f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 30 Mar 2026 18:35:12 -0500 Subject: [PATCH] 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 --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- cli/src/commands.rs | 4 +- cli/src/native/actions.rs | 27 ++++- cli/src/native/browser.rs | 60 +++++++++- cli/src/native/cdp/client.rs | 2 +- cli/src/native/daemon.rs | 2 + cli/src/native/providers.rs | 99 ++++++++-------- cli/src/native/stream.rs | 8 ++ docs/src/app/api/docs-chat/route.ts | 2 +- docs/src/app/layout.tsx | 12 +- docs/src/lib/page-metadata.ts | 2 +- docs/src/lib/page-titles.ts | 2 +- package.json | 2 +- packages/dashboard/next-env.d.ts | 2 +- .../public/providers/browser-use.svg | 1 + .../public/providers/browserbase.svg | 1 + .../public/providers/browserless.svg | 1 + .../dashboard/public/providers/kernel.svg | 1 + .../dashboard/src/components/session-tree.tsx | 107 ++++++++++++++---- packages/dashboard/src/store/sessions.ts | 41 +++++-- packages/dashboard/src/types.ts | 1 + 22 files changed, 274 insertions(+), 107 deletions(-) create mode 100644 packages/dashboard/public/providers/browser-use.svg create mode 100644 packages/dashboard/public/providers/browserbase.svg create mode 100644 packages/dashboard/public/providers/browserless.svg create mode 100644 packages/dashboard/public/providers/kernel.svg diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6532d8a..3560fe6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "agent-browser", - "description": "Headless browser automation for AI agents", + "description": "Browser automation for AI agents", "owner": { "name": "Vercel", "email": "support@vercel.com" diff --git a/README.md b/README.md index aa8ce42..3438851 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # agent-browser -Headless browser automation CLI for AI agents. Fast native Rust CLI. +Browser automation CLI for AI agents. Fast native Rust CLI. ## Installation diff --git a/cli/src/commands.rs b/cli/src/commands.rs index aa24ceb..661595c 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -112,7 +112,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(headers_json).map_err(|_| { diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index f85460b..4cb6757 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1618,8 +1618,13 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result { - let (ws_url, provider_session) = providers::connect_provider(provider).await?; - match BrowserManager::connect_cdp(&ws_url).await { + let conn = providers::connect_provider(provider).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) => { state.reset_input_state(); state.browser = Some(mgr); @@ -1627,10 +1632,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result { - if let Some(ref ps) = provider_session { + if let Some(ref ps) = conn.session { providers::close_provider_session(ps).await; } return Err(e); @@ -1810,6 +1816,7 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result Result 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 { get_socket_dir().join(format!("{}.extensions", session_id)) } @@ -4644,6 +4664,7 @@ async fn handle_stream_disable(state: &mut DaemonState) -> Result state.stream_client = None; remove_stream_file(&state.session_id)?; remove_engine_file(&state.session_id); + remove_provider_file(&state.session_id); Ok(json!({ "disabled": true })) } diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 1ac1539..2bbe39b 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -149,6 +149,7 @@ pub enum WaitUntil { Load, DomContentLoaded, NetworkIdle, + None, } impl WaitUntil { @@ -156,6 +157,7 @@ impl WaitUntil { match s { "domcontentloaded" => Self::DomContentLoaded, "networkidle" => Self::NetworkIdle, + "none" => Self::None, _ => Self::Load, } } @@ -326,6 +328,16 @@ impl BrowserManager { } pub async fn connect_cdp(url: &str) -> Result { + 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::connect_cdp_inner(url, true).await + } + + async fn connect_cdp_inner(url: &str, direct_page: bool) -> Result { let ws_url = resolve_cdp_url(url).await?; let client = Arc::new(CdpClient::connect(&ws_url).await?); let mut manager = Self { @@ -334,12 +346,24 @@ impl BrowserManager { ws_url, pages: Vec::new(), active_page_index: 0, - default_timeout_ms: 10_000, - download_path: None, // CDP connections don't have a launch-time download path + default_timeout_ms: 25_000, + download_path: None, 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) } @@ -465,6 +489,20 @@ impl BrowserManager { 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> { self.pages .get(self.active_page_index) @@ -495,7 +533,7 @@ impl BrowserManager { // 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) // 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) .await?; } @@ -529,6 +567,7 @@ impl BrowserManager { WaitUntil::Load => "Page.loadEventFired", WaitUntil::DomContentLoaded => "Page.domContentEventFired", 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); @@ -1366,6 +1405,19 @@ async fn resolve_cdp_url(input: &str) -> Result { if input.starts_with("http://") || input.starts_with("https://") { 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 .host_str() .ok_or_else(|| format!("No host in CDP URL: {}", input))?; diff --git a/cli/src/native/cdp/client.rs b/cli/src/native/cdp/client.rs index d5353e3..5450d05 100644 --- a/cli/src/native/cdp/client.rs +++ b/cli/src/native/cdp/client.rs @@ -199,7 +199,7 @@ impl CdpClient { id, method: method.to_string(), 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) diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 62a93e4..d67adc8 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -63,6 +63,7 @@ pub async fn run_daemon(session: &str) { let stream_path = socket_dir.join(format!("{}.stream", session)); let _ = fs::remove_file(&stream_path); 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))); 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(&stream_path); 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))); if let Err(e) = result { diff --git a/cli/src/native/providers.rs b/cli/src/native/providers.rs index d29703e..79089f4 100644 --- a/cli/src/native/providers.rs +++ b/cli/src/native/providers.rs @@ -12,16 +12,49 @@ pub struct ProviderSession { pub session_id: String, } +pub struct ProviderConnection { + pub ws_url: String, + pub session: Option, + /// 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 /// along with session info for cleanup on failure. -pub async fn connect_provider( - provider_name: &str, -) -> Result<(String, Option), String> { +pub async fn connect_provider(provider_name: &str) -> Result { match provider_name.to_lowercase().as_str() { - "browserbase" => connect_browserbase().await, - "browserless" => connect_browserless().await, - "browser-use" | "browseruse" => connect_browser_use().await, - "kernel" => connect_kernel().await, + "browserbase" => { + let (url, session) = connect_browserbase().await?; + Ok(ProviderConnection { + 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!( "Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel", provider_name @@ -91,7 +124,9 @@ async fn connect_browserbase() -> Result<(String, Option), Stri let client = reqwest::Client::new(); let response = client .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() .await .map_err(|e| format!("Browserbase request failed: {}", e))?; @@ -219,53 +254,9 @@ async fn connect_browser_use() -> Result<(String, Option), Stri let api_key = env::var("BROWSER_USE_API_KEY") .map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?; - let client = reqwest::Client::new(); - 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 ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key); - let status = response.status(); - 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, - }), - )) + Ok((ws_url, None)) } async fn connect_kernel() -> Result<(String, Option), String> { diff --git a/cli/src/native/stream.rs b/cli/src/native/stream.rs index 2d4f67d..795952f 100644 --- a/cli/src/native/stream.rs +++ b/cli/src/native/stream.rs @@ -1322,6 +1322,11 @@ fn discover_sessions() -> String { .filter(|s| !s.trim().is_empty()) .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 mut entry = json!({ @@ -1329,6 +1334,9 @@ fn discover_sessions() -> String { "port": port, "engine": engine.trim(), }); + if let Some(ref p) = provider { + entry["provider"] = json!(p.trim()); + } if !extensions.is_empty() { entry["extensions"] = json!(extensions); } diff --git a/docs/src/app/api/docs-chat/route.ts b/docs/src/app/api/docs-chat/route.ts index ab43afc..4f7f062 100644 --- a/docs/src/app/api/docs-chat/route.ts +++ b/docs/src/app/api/docs-chat/route.ts @@ -12,7 +12,7 @@ export const maxDuration = 60; 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 Documentation: https://agent-browser.dev diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index 445b084..c09d2b7 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -25,23 +25,23 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { metadataBase: new URL("https://agent-browser.dev"), title: { - default: "agent-browser | Headless Browser Automation for AI", + default: "agent-browser | Browser Automation for AI", template: "%s | agent-browser", }, - description: "Headless browser automation CLI for AI agents", + description: "Browser automation CLI for AI agents", openGraph: { type: "website", locale: "en_US", url: "https://agent-browser.dev", siteName: "agent-browser", - title: "agent-browser | Headless Browser Automation for AI", - description: "Headless browser automation CLI for AI agents", + title: "agent-browser | Browser Automation for AI", + description: "Browser automation CLI for AI agents", images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }], }, twitter: { card: "summary_large_image", - title: "agent-browser | Headless Browser Automation for AI", - description: "Headless browser automation CLI for AI agents", + title: "agent-browser | Browser Automation for AI", + description: "Browser automation CLI for AI agents", images: ["/og"], }, }; diff --git a/docs/src/lib/page-metadata.ts b/docs/src/lib/page-metadata.ts index 2246af4..a9b3932 100644 --- a/docs/src/lib/page-metadata.ts +++ b/docs/src/lib/page-metadata.ts @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { PAGE_TITLES } from "./page-titles"; const DESCRIPTION = - "Headless browser automation CLI for AI agents"; + "Browser automation CLI for AI agents"; export function pageMetadata(slug: string): Metadata { const title = PAGE_TITLES[slug]; diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index ec29dc7..238b182 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -1,5 +1,5 @@ export const PAGE_TITLES: Record = { - "": "Headless Browser\nAutomation for AI", + "": "Browser\nAutomation for AI", installation: "Installation", "quick-start": "Quick Start", skills: "Skills", diff --git a/package.json b/package.json index d9b63dc..77bd25b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agent-browser", "version": "0.23.1", - "description": "Headless browser automation CLI for AI agents", + "description": "Browser automation CLI for AI agents", "type": "module", "files": [ "bin", diff --git a/packages/dashboard/next-env.d.ts b/packages/dashboard/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/packages/dashboard/next-env.d.ts +++ b/packages/dashboard/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/dashboard/public/providers/browser-use.svg b/packages/dashboard/public/providers/browser-use.svg new file mode 100644 index 0000000..6e38485 --- /dev/null +++ b/packages/dashboard/public/providers/browser-use.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/dashboard/public/providers/browserbase.svg b/packages/dashboard/public/providers/browserbase.svg new file mode 100644 index 0000000..05136a7 --- /dev/null +++ b/packages/dashboard/public/providers/browserbase.svg @@ -0,0 +1 @@ + diff --git a/packages/dashboard/public/providers/browserless.svg b/packages/dashboard/public/providers/browserless.svg new file mode 100644 index 0000000..8346b48 --- /dev/null +++ b/packages/dashboard/public/providers/browserless.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/dashboard/public/providers/kernel.svg b/packages/dashboard/public/providers/kernel.svg new file mode 100644 index 0000000..10c9a4e --- /dev/null +++ b/packages/dashboard/public/providers/kernel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/dashboard/src/components/session-tree.tsx b/packages/dashboard/src/components/session-tree.tsx index 7e73ba3..39a8bbb 100644 --- a/packages/dashboard/src/components/session-tree.tsx +++ b/packages/dashboard/src/components/session-tree.tsx @@ -52,24 +52,40 @@ const ENGINE_LOGOS: Record = { lightpanda: "/lightpanda.svg", }; +const PROVIDER_LOGOS: Record = { + 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; -function EngineLogo({ engine }: { engine: string }) { - const src = ENGINE_LOGOS[engine]; +const BROWSER_OPTIONS: { id: string; label: string; engine?: string; provider?: string }[] = [ + { 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 }) { + const src = logos[name]; if (!src) { - if (!engine) { + if (!name) { return ; } return ( - {engine.charAt(0)} + {name.charAt(0)} ); } return ( {engine}; +} + +function ProviderLogo({ provider }: { provider: string }) { + return ; +} + function getFaviconUrl(url: string): string | null { try { const { hostname } = new URL(url); @@ -152,6 +176,7 @@ function SessionNode({ isActive, tabs, engine, + provider, expanded, onSelect, onToggle, @@ -165,6 +190,7 @@ function SessionNode({ isActive: boolean; tabs: TabInfo[]; engine: string; + provider: string; expanded: boolean; onSelect: () => void; onToggle: () => void; @@ -184,7 +210,9 @@ function SessionNode({ - + {(session.provider ?? provider) + ? + : } {session.session} @@ -212,7 +240,9 @@ function SessionNode({ - + {provider + ? + : } {session.session} @@ -318,7 +348,9 @@ export function SessionTree() { const [newSessionOpen, setNewSessionOpen] = useState(false); const [closeAllOpen, setCloseAllOpen] = useState(false); 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(null); const isExpanded = useCallback( @@ -330,14 +362,25 @@ export function SessionTree() { setExpandedMap((prev) => ({ ...prev, [port]: !(prev[port] ?? true) })); }, []); - const handleCreateSubmit = useCallback(() => { + const handleCreateSubmit = useCallback(async () => { const name = newSessionName.trim(); - if (name) { - dispatchCreateSession({ name, engine: newSessionEngine }); + if (!name || creating) return; + 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(""); setNewSessionOpen(false); } - }, [newSessionName, newSessionEngine, dispatchCreateSession]); + }, [newSessionName, newSessionBrowser, creating, dispatchCreateSession]); return (
@@ -379,6 +422,7 @@ export function SessionTree() { isActive={s.port === activePort} tabs={getTabsForSession(s.port)} engine={getEngineForSession(s.port)} + provider={s.provider ?? ""} expanded={isExpanded(s.port)} onSelect={() => setActivePort(s.port)} onToggle={() => toggleExpanded(s.port)} @@ -393,8 +437,11 @@ export function SessionTree() {
- - + { + setNewSessionOpen(open); + if (open) setCreateError(""); + }}> + New session @@ -411,40 +458,52 @@ export function SessionTree() { }} placeholder="Session name" 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" /> -
- {SUPPORTED_ENGINES.map((eng) => ( +
+ {BROWSER_OPTIONS.map((opt) => ( ))}
+ {createError && ( +

+ {createError} +

+ )} diff --git a/packages/dashboard/src/store/sessions.ts b/packages/dashboard/src/store/sessions.ts index 76f75bc..4d49886 100644 --- a/packages/dashboard/src/store/sessions.ts +++ b/packages/dashboard/src/store/sessions.ts @@ -4,7 +4,7 @@ import { atom } from "jotai"; import { useCallback, useEffect, useRef } from "react"; import { useAtomCallback } from "jotai/utils"; 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 { streamTabsAtom, streamEngineAtom } from "@/store/stream"; @@ -35,7 +35,7 @@ export const activePortAtom = atom(getPort()); export const polledSessionsAtom = atom([]); -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, port: 0, engine: p.engine, + provider: p.provider, pending: true as const, })); const merged = polled.map((s) => @@ -88,16 +89,42 @@ export const activeExtensionsAtom = atom((get) => { export const createSessionAtom = atom( null, - ( + async ( _get, set, - { name, engine }: { name: string; engine: string }, - ) => { - set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine }]); - execCommand(["--session", name, "--engine", engine, "open", "about:blank"]); + { name, engine, provider }: { name: string; engine: string; provider?: string }, + ): Promise => { + set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine, provider }]); + 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) => { const sessions = get(sessionsAtom); const s = sessions.find((x) => x.port === port)?.session; diff --git a/packages/dashboard/src/types.ts b/packages/dashboard/src/types.ts index a287424..ebdd4b0 100644 --- a/packages/dashboard/src/types.ts +++ b/packages/dashboard/src/types.ts @@ -105,6 +105,7 @@ export interface SessionInfo { session: string; port: number; engine?: string; + provider?: string; extensions?: ExtensionInfo[]; pending?: boolean; closing?: boolean;