From 589362ffc0fc5475635e1a78482f20c66ed59e13 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 17 Mar 2026 06:43:37 -0700 Subject: [PATCH] feat: [US-027] - Add Browser tab - screenshot, tabs, and console sections Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/debug/BrowserTab.tsx | 434 +++++++++++++++++- 1 file changed, 431 insertions(+), 3 deletions(-) diff --git a/frontend/packages/inspector/src/components/debug/BrowserTab.tsx b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx index b6495c3..8f75954 100644 --- a/frontend/packages/inspector/src/components/debug/BrowserTab.tsx +++ b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx @@ -1,7 +1,7 @@ -import { ArrowLeft, ArrowRight, Globe, Loader2, Play, RefreshCw, Square } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowLeft, ArrowRight, Camera, Globe, Layers, Loader2, Play, Plus, RefreshCw, Square, Terminal, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SandboxAgentError } from "sandbox-agent"; -import type { BrowserContextInfo, BrowserStatusResponse, SandboxAgent } from "sandbox-agent"; +import type { BrowserConsoleMessage, BrowserContextInfo, BrowserStatusResponse, BrowserTabInfo, SandboxAgent } from "sandbox-agent"; import { DesktopViewer } from "@sandbox-agent/react"; import type { BrowserViewerClient } from "@sandbox-agent/react"; @@ -19,6 +19,42 @@ const formatStartedAt = (value: string | null | undefined): string => { return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); }; +const createScreenshotUrl = async (bytes: Uint8Array, mimeType = "image/png"): Promise => { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const blob = new Blob([payload.buffer], { type: mimeType }); + if (typeof URL.createObjectURL === "function") { + return URL.createObjectURL(blob); + } + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("Unable to read screenshot blob.")); + } + }; + reader.readAsDataURL(blob); + }); +}; + +const CONSOLE_LEVELS = ["all", "log", "warn", "error", "info"] as const; + +const consoleLevelColor = (level: string): string => { + switch (level) { + case "error": + return "var(--danger, #ef4444)"; + case "warning": + return "var(--warning, #f59e0b)"; + case "info": + return "var(--info, #3b82f6)"; + default: + return "var(--muted)"; + } +}; + const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { // Status const [status, setStatus] = useState(null); @@ -34,6 +70,29 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { const [contextId, setContextId] = useState(""); const [contexts, setContexts] = useState([]); + // Screenshot + const [screenshotUrl, setScreenshotUrl] = useState(null); + const [screenshotLoading, setScreenshotLoading] = useState(false); + const [screenshotError, setScreenshotError] = useState(null); + const [screenshotFormat, setScreenshotFormat] = useState<"png" | "jpeg" | "webp">("png"); + const [screenshotQuality, setScreenshotQuality] = useState("85"); + const [screenshotFullPage, setScreenshotFullPage] = useState(false); + const [screenshotSelector, setScreenshotSelector] = useState(""); + + // Tabs + const [tabs, setTabs] = useState([]); + const [tabsLoading, setTabsLoading] = useState(false); + const [tabsError, setTabsError] = useState(null); + const [newTabUrl, setNewTabUrl] = useState(""); + const [tabActing, setTabActing] = useState(null); + + // Console + const [consoleMessages, setConsoleMessages] = useState([]); + const [consoleLoading, setConsoleLoading] = useState(false); + const [consoleError, setConsoleError] = useState(null); + const [consoleLevel, setConsoleLevel] = useState("all"); + const consoleEndRef = useRef(null); + // Live view const [liveViewActive, setLiveViewActive] = useState(false); const [liveViewError, setLiveViewError] = useState(null); @@ -90,6 +149,108 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { } }, [getClient]); + // Screenshot + const revokeScreenshotUrl = useCallback(() => { + setScreenshotUrl((current) => { + if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(current); + } + return null; + }); + }, []); + + const refreshScreenshot = useCallback(async () => { + setScreenshotLoading(true); + setScreenshotError(null); + try { + const quality = Number.parseInt(screenshotQuality, 10); + const request: Parameters[0] = { + format: screenshotFormat !== "png" ? screenshotFormat : undefined, + quality: screenshotFormat !== "png" && Number.isFinite(quality) ? quality : undefined, + fullPage: screenshotFullPage || undefined, + selector: screenshotSelector.trim() || undefined, + }; + const bytes = await getClient().takeBrowserScreenshot(request); + revokeScreenshotUrl(); + const mimeType = screenshotFormat === "jpeg" ? "image/jpeg" : screenshotFormat === "webp" ? "image/webp" : "image/png"; + setScreenshotUrl(await createScreenshotUrl(bytes, mimeType)); + } catch (captureError) { + revokeScreenshotUrl(); + setScreenshotError(extractErrorMessage(captureError, "Unable to capture browser screenshot.")); + } finally { + setScreenshotLoading(false); + } + }, [getClient, revokeScreenshotUrl, screenshotFormat, screenshotQuality, screenshotFullPage, screenshotSelector]); + + // Tabs + const loadTabs = useCallback(async () => { + setTabsLoading(true); + setTabsError(null); + try { + const result = await getClient().getBrowserTabs(); + setTabs(result.tabs); + } catch (err) { + setTabsError(extractErrorMessage(err, "Unable to load tabs.")); + } finally { + setTabsLoading(false); + } + }, [getClient]); + + const handleCreateTab = async () => { + setTabActing("new"); + setTabsError(null); + try { + await getClient().createBrowserTab(newTabUrl.trim() ? { url: newTabUrl.trim() } : {}); + setNewTabUrl(""); + await loadTabs(); + } catch (err) { + setTabsError(extractErrorMessage(err, "Unable to create tab.")); + } finally { + setTabActing(null); + } + }; + + const handleActivateTab = async (tabId: string) => { + setTabActing(tabId); + setTabsError(null); + try { + await getClient().activateBrowserTab(tabId); + await loadTabs(); + } catch (err) { + setTabsError(extractErrorMessage(err, "Unable to activate tab.")); + } finally { + setTabActing(null); + } + }; + + const handleCloseTab = async (tabId: string) => { + setTabActing(tabId); + setTabsError(null); + try { + await getClient().closeBrowserTab(tabId); + await loadTabs(); + } catch (err) { + setTabsError(extractErrorMessage(err, "Unable to close tab.")); + } finally { + setTabActing(null); + } + }; + + // Console + const loadConsole = useCallback(async () => { + setConsoleLoading(true); + setConsoleError(null); + try { + const query = consoleLevel !== "all" ? { level: consoleLevel } : {}; + const result = await getClient().getBrowserConsole(query); + setConsoleMessages(result.messages); + } catch (err) { + setConsoleError(extractErrorMessage(err, "Unable to load console messages.")); + } finally { + setConsoleLoading(false); + } + }, [getClient, consoleLevel]); + // Initial load useEffect(() => { void loadStatus(); @@ -103,6 +264,26 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { return () => clearInterval(interval); }, [status?.state, loadStatus]); + // Load tabs and console when browser becomes active + useEffect(() => { + if (status?.state === "active") { + void loadTabs(); + void loadConsole(); + } + }, [status?.state, loadTabs, loadConsole]); + + // Auto-refresh console every 3s when active + useEffect(() => { + if (status?.state !== "active") return; + const interval = setInterval(() => void loadConsole(), 3000); + return () => clearInterval(interval); + }, [status?.state, loadConsole]); + + // Cleanup screenshot URL on unmount + useEffect(() => { + return () => revokeScreenshotUrl(); + }, [revokeScreenshotUrl]); + // Reset live view when browser becomes inactive useEffect(() => { if (status?.state !== "active") { @@ -438,6 +619,253 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { {isActive && !liveViewActive &&
Click "Start Stream" for live browser view.
} + + {/* ========== Screenshot Section ========== */} + {isActive && ( +
+
+ + + Screenshot + + +
+ +
+
+ + +
+ {screenshotFormat !== "png" && ( +
+ + setScreenshotQuality(e.target.value)} + inputMode="numeric" + style={{ maxWidth: 60 }} + /> +
+ )} + +
+ + setScreenshotSelector(e.target.value)} + placeholder="e.g. #main" + style={{ maxWidth: 140 }} + /> +
+
+ + {screenshotError && ( +
+ {screenshotError} +
+ )} + + {screenshotUrl ? ( +
+ Browser screenshot +
+ ) : ( +
Click "Capture" to take a browser screenshot.
+ )} +
+ )} + + {/* ========== Tabs Section ========== */} + {isActive && ( +
+
+ + + Tabs + + +
+ + {tabsError && ( +
+ {tabsError} +
+ )} + + {tabs.length > 0 ? ( +
+ {tabs.map((tab) => ( +
+
+
+ {tab.title || "(untitled)"} + {tab.active && ( + + active + + )} +
+ {tab.url} +
+
+
+ {!tab.active && ( + + )} + +
+
+
+ ))} +
+ ) : ( +
No tabs open.
+ )} + +
+ setNewTabUrl(e.target.value)} + placeholder="https://example.com" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateTab(); + }} + style={{ flex: 1, fontSize: 11 }} + /> + +
+
+ )} + + {/* ========== Console Section ========== */} + {isActive && ( +
+
+ + + Console + +
+ +
+
+ + {/* Level filter pills */} +
+ {CONSOLE_LEVELS.map((level) => ( + + ))} +
+ + {consoleError && ( +
+ {consoleError} +
+ )} + + {consoleMessages.length > 0 ? ( +
+ {consoleMessages.map((msg, idx) => ( +
+ + {msg.level} + {msg.text} + {new Date(msg.timestamp).toLocaleTimeString()} +
+ ))} +
+
+ ) : ( +
No console messages.
+ )} +
+ )}
); };