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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface SessionInfo {
|
||||
session: string;
|
||||
port: number;
|
||||
engine?: string;
|
||||
provider?: string;
|
||||
extensions?: ExtensionInfo[];
|
||||
pending?: boolean;
|
||||
closing?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user