diff --git a/frontend/packages/inspector/src/components/debug/BrowserTab.tsx b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx new file mode 100644 index 0000000..b6495c3 --- /dev/null +++ b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx @@ -0,0 +1,445 @@ +import { ArrowLeft, ArrowRight, Globe, Loader2, Play, RefreshCw, Square } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { SandboxAgentError } from "sandbox-agent"; +import type { BrowserContextInfo, BrowserStatusResponse, SandboxAgent } from "sandbox-agent"; +import { DesktopViewer } from "@sandbox-agent/react"; +import type { BrowserViewerClient } from "@sandbox-agent/react"; + +const MIN_SPIN_MS = 350; + +const extractErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail; + if (error instanceof Error) return error.message; + return fallback; +}; + +const formatStartedAt = (value: string | null | undefined): string => { + if (!value) return "Not started"; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); +}; + +const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { + // Status + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [acting, setActing] = useState<"start" | "stop" | null>(null); + const [error, setError] = useState(null); + + // Config inputs + const [width, setWidth] = useState("1280"); + const [height, setHeight] = useState("720"); + const [startUrl, setStartUrl] = useState(""); + const [contextId, setContextId] = useState(""); + const [contexts, setContexts] = useState([]); + + // Live view + const [liveViewActive, setLiveViewActive] = useState(false); + const [liveViewError, setLiveViewError] = useState(null); + const [navUrl, setNavUrl] = useState(""); + const [isNavigating, setIsNavigating] = useState(false); + + const isActive = status?.state === "active"; + + const resolutionLabel = useMemo(() => { + const resolution = status?.resolution; + if (!resolution) return "Unknown"; + return `${resolution.width} x ${resolution.height}`; + }, [status?.resolution]); + + const viewerClient = useMemo(() => { + const c = getClient(); + return { + connectDesktopStream: (opts?: Parameters[0]) => c.connectDesktopStream(opts), + browserNavigate: (req) => c.browserNavigate(req), + browserBack: () => c.browserBack(), + browserForward: () => c.browserForward(), + browserReload: (req?) => c.browserReload(req), + getBrowserStatus: () => c.getBrowserStatus(), + }; + }, [getClient]); + + const loadStatus = useCallback( + async (mode: "initial" | "refresh" = "initial") => { + if (mode === "initial") setLoading(true); + else setRefreshing(true); + setError(null); + try { + const next = await getClient().getBrowserStatus(); + setStatus(next); + if (next.url) setNavUrl(next.url); + return next; + } catch (loadError) { + setError(extractErrorMessage(loadError, "Unable to load browser status.")); + return null; + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [getClient], + ); + + const loadContexts = useCallback(async () => { + try { + const result = await getClient().getBrowserContexts(); + setContexts(result.contexts); + } catch { + // non-critical + } + }, [getClient]); + + // Initial load + useEffect(() => { + void loadStatus(); + void loadContexts(); + }, [loadStatus, loadContexts]); + + // Auto-refresh status every 5s when active + useEffect(() => { + if (status?.state !== "active") return; + const interval = setInterval(() => void loadStatus("refresh"), 5000); + return () => clearInterval(interval); + }, [status?.state, loadStatus]); + + // Reset live view when browser becomes inactive + useEffect(() => { + if (status?.state !== "active") { + setLiveViewActive(false); + } + }, [status?.state]); + + const handleStart = async () => { + const parsedWidth = Number.parseInt(width, 10); + const parsedHeight = Number.parseInt(height, 10); + setActing("start"); + setError(null); + const startedAt = Date.now(); + try { + const request: Parameters[0] = { + width: Number.isFinite(parsedWidth) ? parsedWidth : undefined, + height: Number.isFinite(parsedHeight) ? parsedHeight : undefined, + url: startUrl.trim() || undefined, + contextId: contextId || undefined, + }; + const next = await getClient().startBrowser(request); + setStatus(next); + if (next.url) setNavUrl(next.url); + } catch (startError) { + setError(extractErrorMessage(startError, "Unable to start browser.")); + await loadStatus("refresh"); + } finally { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < MIN_SPIN_MS) { + await new Promise((resolve) => window.setTimeout(resolve, MIN_SPIN_MS - elapsedMs)); + } + setActing(null); + } + }; + + const handleStop = async () => { + setActing("stop"); + setError(null); + const startedAt = Date.now(); + try { + const next = await getClient().stopBrowser(); + setStatus(next); + setLiveViewActive(false); + } catch (stopError) { + setError(extractErrorMessage(stopError, "Unable to stop browser.")); + await loadStatus("refresh"); + } finally { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < MIN_SPIN_MS) { + await new Promise((resolve) => window.setTimeout(resolve, MIN_SPIN_MS - elapsedMs)); + } + setActing(null); + } + }; + + const handleNavigate = async (url: string) => { + if (!url.trim()) return; + setIsNavigating(true); + try { + let normalizedUrl = url.trim(); + if (!/^https?:\/\//i.test(normalizedUrl)) { + normalizedUrl = `https://${normalizedUrl}`; + } + const page = await getClient().browserNavigate({ url: normalizedUrl }); + setNavUrl(page.url ?? ""); + } catch { + // navigation error silently ignored + } finally { + setIsNavigating(false); + } + }; + + const handleBack = async () => { + setIsNavigating(true); + try { + const page = await getClient().browserBack(); + setNavUrl(page.url ?? ""); + } catch { + // ignore + } finally { + setIsNavigating(false); + } + }; + + const handleForward = async () => { + setIsNavigating(true); + try { + const page = await getClient().browserForward(); + setNavUrl(page.url ?? ""); + } catch { + // ignore + } finally { + setIsNavigating(false); + } + }; + + const handleReload = async () => { + setIsNavigating(true); + try { + const page = await getClient().browserReload(); + setNavUrl(page.url ?? ""); + } catch { + // ignore + } finally { + setIsNavigating(false); + } + }; + + return ( +
+
+ +
+ + {error &&
{error}
} + + {/* ========== Runtime Control Section ========== */} +
+
+ + + Browser Runtime + + + {status?.state ?? "unknown"} + +
+ +
+
+
URL
+
+ {status?.url ?? "None"} +
+
+
+
Resolution
+
{resolutionLabel}
+
+
+
Started
+
{formatStartedAt(status?.startedAt)}
+
+
+ +
+
+ + setWidth(e.target.value)} inputMode="numeric" /> +
+
+ + setHeight(e.target.value)} inputMode="numeric" /> +
+
+ + setStartUrl(e.target.value)} placeholder="https://example.com" /> +
+
+ + +
+
+ +
+ {isActive ? ( + + ) : ( + + )} +
+
+ + {/* ========== Missing Dependencies ========== */} + {status?.missingDependencies && status.missingDependencies.length > 0 && ( +
+
+ Missing Dependencies +
+
+ {status.missingDependencies.map((dep) => ( + + {dep} + + ))} +
+ {status.installCommand && ( + <> +
+ Install command +
+
{status.installCommand}
+ + )} +
+ )} + + {/* ========== Live View Section ========== */} +
+
+ + + Live View + + {isActive && ( + + )} +
+ + {liveViewError && ( +
+ {liveViewError} +
+ )} + + {!isActive &&
Start the browser runtime to enable live view.
} + + {isActive && liveViewActive && ( + <> + {/* Navigation Bar */} +
+ + + + setNavUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleNavigate(navUrl); + } + }} + placeholder="Enter URL..." + style={{ flex: 1, fontSize: 11 }} + /> +
+ + + + {status?.url && ( +
+ {status.url} +
+ )} + + )} + + {isActive && !liveViewActive &&
Click "Start Stream" for live browser view.
} +
+
+ ); +}; + +export default BrowserTab; diff --git a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx index 9855d38..163caaa 100644 --- a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx +++ b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, ChevronRight, Cloud, Monitor, Play, PlayCircle, Server, Terminal, Wrench } from "lucide-react"; +import { ChevronLeft, ChevronRight, Cloud, Globe, Monitor, Play, PlayCircle, Server, Terminal, Wrench } from "lucide-react"; import type { AgentInfo, SandboxAgent, SessionEvent } from "sandbox-agent"; type AgentModeInfo = { id: string; name: string; description: string }; @@ -9,10 +9,11 @@ import ProcessesTab from "./ProcessesTab"; import ProcessRunTab from "./ProcessRunTab"; import SkillsTab from "./SkillsTab"; import RequestLogTab from "./RequestLogTab"; +import BrowserTab from "./BrowserTab"; import DesktopTab from "./DesktopTab"; import type { RequestLog } from "../../types/requestLog"; -export type DebugTab = "log" | "events" | "agents" | "desktop" | "mcp" | "skills" | "processes" | "run-process"; +export type DebugTab = "log" | "events" | "agents" | "desktop" | "browser" | "mcp" | "skills" | "processes" | "run-process"; const DebugPanel = ({ debugTab, @@ -80,6 +81,10 @@ const DebugPanel = ({ Desktop +