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,6 +1,6 @@
/// <reference types="next" />
/// <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
// 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",
};
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;