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
286 lines
8.8 KiB
TypeScript
286 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { atom } from "jotai";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { useAtomCallback } from "jotai/utils";
|
|
import type { SessionInfo } from "@/types";
|
|
import { type ExecResult, execCommand, killSession, sessionArgs } from "@/lib/exec";
|
|
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
|
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
|
|
|
function getPort(): number {
|
|
if (typeof window === "undefined") return 9223;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const p = params.get("port");
|
|
return p ? parseInt(p, 10) || 9223 : 9223;
|
|
}
|
|
|
|
const DASHBOARD_PORT = 4848;
|
|
|
|
function getSessionsUrl(): string {
|
|
if (typeof window !== "undefined") {
|
|
const origin = window.location.origin;
|
|
if (origin.includes(`:${DASHBOARD_PORT}`)) {
|
|
return "/api/sessions";
|
|
}
|
|
}
|
|
return `http://localhost:${DASHBOARD_PORT}/api/sessions`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Primitive atoms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const activePortAtom = atom(getPort());
|
|
|
|
export const polledSessionsAtom = atom<SessionInfo[]>([]);
|
|
|
|
export const pendingSessionsAtom = atom<{ session: string; engine: string; provider?: string }[]>(
|
|
[],
|
|
);
|
|
|
|
export const closingSessionsAtom = atom<Set<string>>(new Set<string>());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derived atoms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const sessionsAtom = atom((get) => {
|
|
const polled = get(polledSessionsAtom);
|
|
const pending = get(pendingSessionsAtom);
|
|
const closing = get(closingSessionsAtom);
|
|
|
|
const polledNames = new Set(polled.map((s) => s.session));
|
|
const pendingEntries = pending
|
|
.filter((p) => !polledNames.has(p.session))
|
|
.map((p) => ({
|
|
session: p.session,
|
|
port: 0,
|
|
engine: p.engine,
|
|
provider: p.provider,
|
|
pending: true as const,
|
|
}));
|
|
const merged = polled.map((s) =>
|
|
closing.has(s.session) ? { ...s, closing: true as const } : s,
|
|
);
|
|
return [...merged, ...pendingEntries];
|
|
});
|
|
|
|
export const activeSessionInfoAtom = atom((get) => {
|
|
const sessions = get(sessionsAtom);
|
|
const port = get(activePortAtom);
|
|
return sessions.find((s) => s.port === port);
|
|
});
|
|
|
|
export const activeSessionNameAtom = atom(
|
|
(get) => get(activeSessionInfoAtom)?.session ?? "",
|
|
);
|
|
|
|
export const activeExtensionsAtom = atom((get) => {
|
|
const info = get(activeSessionInfoAtom);
|
|
return (
|
|
(info && "extensions" in info ? info.extensions : undefined) ?? []
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Action atoms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const createSessionAtom = atom(
|
|
null,
|
|
async (
|
|
_get,
|
|
set,
|
|
{ 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;
|
|
if (s) {
|
|
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
|
execCommand(sessionArgs(s, "close"));
|
|
}
|
|
});
|
|
|
|
export const killSessionAtom = atom(null, (get, set, port: number) => {
|
|
const sessions = get(sessionsAtom);
|
|
const s = sessions.find((x) => x.port === port)?.session;
|
|
if (s) {
|
|
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
|
killSession(s);
|
|
}
|
|
});
|
|
|
|
export const closeAllSessionsAtom = atom(null, (get, set) => {
|
|
const sessions = get(sessionsAtom);
|
|
for (const s of sessions) {
|
|
if (!s.pending && !s.closing) {
|
|
set(closingSessionsAtom, (prev) => new Set(prev).add(s.session));
|
|
execCommand(sessionArgs(s.session, "close"));
|
|
}
|
|
}
|
|
});
|
|
|
|
export const closeTabAtom = atom(
|
|
null,
|
|
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
|
const sessions = get(sessionsAtom);
|
|
const s = sessions.find((x) => x.port === port)?.session;
|
|
if (s) execCommand(sessionArgs(s, "tab", "close", String(tabIndex)));
|
|
},
|
|
);
|
|
|
|
export const addTabAtom = atom(null, (get, _set, port: number) => {
|
|
const sessions = get(sessionsAtom);
|
|
const s = sessions.find((x) => x.port === port)?.session;
|
|
if (s) execCommand(sessionArgs(s, "tab", "new"));
|
|
});
|
|
|
|
export const switchTabAtom = atom(
|
|
null,
|
|
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
|
const sessions = get(sessionsAtom);
|
|
const s = sessions.find((x) => x.port === port)?.session;
|
|
if (s) execCommand(sessionArgs(s, "tab", String(tabIndex)));
|
|
},
|
|
);
|
|
|
|
/** Prune pending/closing once the polled list confirms them */
|
|
const reconcileSessionsAtom = atom(
|
|
null,
|
|
(get, set) => {
|
|
const polled = get(polledSessionsAtom);
|
|
const polledNames = new Set(polled.map((s) => s.session));
|
|
|
|
set(pendingSessionsAtom, (prev) => {
|
|
const next = prev.filter((p) => !polledNames.has(p.session));
|
|
return next.length === prev.length ? prev : next;
|
|
});
|
|
|
|
set(closingSessionsAtom, (prev) => {
|
|
const next = new Set(prev);
|
|
for (const name of prev) {
|
|
if (!polledNames.has(name)) next.delete(name);
|
|
}
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useSessionsSync(pollInterval = 5000) {
|
|
const failCountRef = useRef(0);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const reconcile = useAtomCallback(
|
|
useCallback((_get, set) => {
|
|
set(reconcileSessionsAtom);
|
|
}, []),
|
|
);
|
|
|
|
const fetchSessions = useAtomCallback(
|
|
useCallback(
|
|
async (get, set) => {
|
|
try {
|
|
const resp = await fetch(getSessionsUrl());
|
|
if (resp.ok) {
|
|
failCountRef.current = 0;
|
|
const data: SessionInfo[] = await resp.json();
|
|
data.sort((a, b) => a.session.localeCompare(b.session));
|
|
set(polledSessionsAtom, data);
|
|
|
|
// Reconcile pending/closing
|
|
reconcile();
|
|
|
|
// Seed engine cache from session list
|
|
const engineCache = get(engineCacheAtom);
|
|
const nextEngine = { ...engineCache };
|
|
let engineChanged = false;
|
|
for (const s of data) {
|
|
if (s.engine && !nextEngine[s.port]) {
|
|
nextEngine[s.port] = s.engine;
|
|
engineChanged = true;
|
|
}
|
|
}
|
|
if (engineChanged) set(engineCacheAtom, nextEngine);
|
|
|
|
// Auto-select first session if current port is not in list
|
|
const activePort = get(activePortAtom);
|
|
const sessions = get(sessionsAtom);
|
|
if (sessions.length > 0 && !sessions.some((s) => s.port === activePort)) {
|
|
set(activePortAtom, sessions[0].port);
|
|
}
|
|
|
|
// Poll tabs for all sessions
|
|
for (const s of data) {
|
|
try {
|
|
const tabsResp = await fetch(
|
|
`http://localhost:${s.port}/api/tabs`,
|
|
).catch(() => null);
|
|
if (tabsResp?.ok) {
|
|
const tabs = await tabsResp.json();
|
|
if (tabs.length > 0) {
|
|
set(tabCacheAtom, (prev) => ({ ...prev, [s.port]: tabs }));
|
|
}
|
|
}
|
|
} catch {
|
|
// Session unreachable
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
} catch {
|
|
// Server unreachable
|
|
}
|
|
failCountRef.current++;
|
|
if (failCountRef.current >= 2) set(polledSessionsAtom, []);
|
|
},
|
|
[reconcile],
|
|
),
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
timerRef.current = setInterval(fetchSessions, pollInterval);
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
}, [fetchSessions, pollInterval]);
|
|
}
|