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
@@ -52,24 +52,40 @@ const ENGINE_LOGOS: Record<string, string> = {
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;
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<string, string> }) {
const src = logos[name];
if (!src) {
if (!engine) {
if (!name) {
return <span className="size-4 shrink-0" />;
}
return (
<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>
);
}
return (
<img
src={src}
alt={engine}
alt={name}
width={16}
height={16}
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 {
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({
<Loader2 className="size-3 animate-spin" />
</span>
<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">
{session.session}
</span>
@@ -212,7 +240,9 @@ function SessionNode({
<ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
</span>
<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">
{session.session}
</span>
@@ -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<HTMLInputElement>(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 (
<div className="flex h-full flex-col">
@@ -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() {
</div>
</ScrollArea>
<Dialog open={newSessionOpen} onOpenChange={setNewSessionOpen}>
<DialogContent className="sm:max-w-sm">
<Dialog open={newSessionOpen} onOpenChange={(open) => {
setNewSessionOpen(open);
if (open) setCreateError("");
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New session</DialogTitle>
</DialogHeader>
@@ -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"
/>
<div className="flex gap-2">
{SUPPORTED_ENGINES.map((eng) => (
<div className="grid grid-cols-3 gap-2">
{BROWSER_OPTIONS.map((opt) => (
<button
key={eng}
key={opt.id}
type="button"
onClick={() => setNewSessionEngine(eng)}
disabled={creating}
onClick={() => setNewSessionBrowser(opt.id)}
className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
newSessionEngine === eng
"flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
newSessionBrowser === opt.id
? "border-ring bg-muted text-foreground"
: "border-border text-muted-foreground hover:text-foreground",
creating && "opacity-50",
)}
>
<EngineLogo engine={eng} />
{eng}
{opt.engine
? <EngineLogo engine={opt.engine} />
: <ProviderLogo provider={opt.provider!} />}
{opt.label}
</button>
))}
</div>
{createError && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{createError}
</p>
)}
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setNewSessionOpen(false)}
disabled={creating}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreateSubmit}
disabled={!newSessionName.trim()}
disabled={!newSessionName.trim() || creating}
>
Create
{creating && <Loader2 className="size-3 animate-spin" />}
{creating ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
+34 -7
View File
@@ -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<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,
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<string | null> => {
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;
+1
View File
@@ -105,6 +105,7 @@ export interface SessionInfo {
session: string;
port: number;
engine?: string;
provider?: string;
extensions?: ExtensionInfo[];
pending?: boolean;
closing?: boolean;