From 65df2735f33658fa7adc7b3c44f6a363e0e61376 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 17 Mar 2026 06:49:18 -0700 Subject: [PATCH] feat: [US-028] - Add Browser tab - network, content tools, recording, contexts, diagnostics sections Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/debug/BrowserTab.tsx | 630 +++++++++++++++++- 1 file changed, 626 insertions(+), 4 deletions(-) diff --git a/frontend/packages/inspector/src/components/debug/BrowserTab.tsx b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx index 8f75954..e215873 100644 --- a/frontend/packages/inspector/src/components/debug/BrowserTab.tsx +++ b/frontend/packages/inspector/src/components/debug/BrowserTab.tsx @@ -1,7 +1,34 @@ -import { ArrowLeft, ArrowRight, Camera, Globe, Layers, Loader2, Play, Plus, RefreshCw, Square, Terminal, X } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + Camera, + Circle, + Code, + Database, + Download, + Globe, + Layers, + Loader2, + Play, + Plus, + RefreshCw, + Square, + Terminal, + Trash2, + Video, + X, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SandboxAgentError } from "sandbox-agent"; -import type { BrowserConsoleMessage, BrowserContextInfo, BrowserStatusResponse, BrowserTabInfo, SandboxAgent } from "sandbox-agent"; +import type { + BrowserConsoleMessage, + BrowserContextInfo, + BrowserNetworkRequest, + BrowserStatusResponse, + BrowserTabInfo, + DesktopRecordingInfo, + SandboxAgent, +} from "sandbox-agent"; import { DesktopViewer } from "@sandbox-agent/react"; import type { BrowserViewerClient } from "@sandbox-agent/react"; @@ -40,6 +67,20 @@ const createScreenshotUrl = async (bytes: Uint8Array, mimeType = "image/png"): P }); }; +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +}; + +const formatDuration = (start: string, end?: string | null): string => { + if (!end) return "in progress"; + const ms = new Date(end).getTime() - new Date(start).getTime(); + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +}; + const CONSOLE_LEVELS = ["all", "log", "warn", "error", "info"] as const; const consoleLevelColor = (level: string): string => { @@ -93,6 +134,31 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { const [consoleLevel, setConsoleLevel] = useState("all"); const consoleEndRef = useRef(null); + // Network + const [networkRequests, setNetworkRequests] = useState([]); + const [networkLoading, setNetworkLoading] = useState(false); + const [networkError, setNetworkError] = useState(null); + const [networkUrlPattern, setNetworkUrlPattern] = useState(""); + + // Content Tools + const [contentOutput, setContentOutput] = useState(""); + const [contentLoading, setContentLoading] = useState(null); + const [contentError, setContentError] = useState(null); + + // Recording + const [recordings, setRecordings] = useState([]); + const [recordingLoading, setRecordingLoading] = useState(false); + const [recordingActing, setRecordingActing] = useState<"start" | "stop" | null>(null); + const [recordingError, setRecordingError] = useState(null); + const [recordingFps, setRecordingFps] = useState("30"); + const [deletingRecordingId, setDeletingRecordingId] = useState(null); + const [downloadingRecordingId, setDownloadingRecordingId] = useState(null); + + // Context management + const [contextName, setContextName] = useState(""); + const [contextActing, setContextActing] = useState(null); + const [contextError, setContextError] = useState(null); + // Live view const [liveViewActive, setLiveViewActive] = useState(false); const [liveViewError, setLiveViewError] = useState(null); @@ -251,6 +317,164 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { } }, [getClient, consoleLevel]); + // Network + const loadNetwork = useCallback(async () => { + setNetworkLoading(true); + setNetworkError(null); + try { + const query = networkUrlPattern.trim() ? { urlPattern: networkUrlPattern.trim() } : {}; + const result = await getClient().getBrowserNetwork(query); + setNetworkRequests(result.requests); + } catch (err) { + setNetworkError(extractErrorMessage(err, "Unable to load network requests.")); + } finally { + setNetworkLoading(false); + } + }, [getClient, networkUrlPattern]); + + // Recording + const activeRecording = useMemo(() => recordings.find((r) => r.status === "recording"), [recordings]); + + const loadRecordings = useCallback(async () => { + setRecordingLoading(true); + setRecordingError(null); + try { + const result = await getClient().listDesktopRecordings(); + setRecordings(result.recordings); + } catch (loadError) { + setRecordingError(extractErrorMessage(loadError, "Unable to load recordings.")); + } finally { + setRecordingLoading(false); + } + }, [getClient]); + + const handleStartRecording = async () => { + const fps = Number.parseInt(recordingFps, 10); + setRecordingActing("start"); + setRecordingError(null); + try { + await getClient().startDesktopRecording({ + fps: Number.isFinite(fps) ? fps : undefined, + }); + await loadRecordings(); + } catch (err) { + setRecordingError(extractErrorMessage(err, "Unable to start recording.")); + } finally { + setRecordingActing(null); + } + }; + + const handleStopRecording = async () => { + setRecordingActing("stop"); + setRecordingError(null); + try { + await getClient().stopDesktopRecording(); + await loadRecordings(); + } catch (err) { + setRecordingError(extractErrorMessage(err, "Unable to stop recording.")); + } finally { + setRecordingActing(null); + } + }; + + const handleDeleteRecording = async (id: string) => { + setDeletingRecordingId(id); + try { + await getClient().deleteDesktopRecording(id); + setRecordings((prev) => prev.filter((r) => r.id !== id)); + } catch (err) { + setRecordingError(extractErrorMessage(err, "Unable to delete recording.")); + } finally { + setDeletingRecordingId(null); + } + }; + + const handleDownloadRecording = async (id: string, fileName: string) => { + setDownloadingRecordingId(id); + try { + const bytes = await getClient().downloadDesktopRecording(id); + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const blob = new Blob([payload.buffer], { type: "video/webm" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setRecordingError(extractErrorMessage(err, "Unable to download recording.")); + } finally { + setDownloadingRecordingId(null); + } + }; + + // Context management + const handleCreateContext = async () => { + if (!contextName.trim()) return; + setContextActing("create"); + setContextError(null); + try { + await getClient().createBrowserContext({ name: contextName.trim() }); + setContextName(""); + await loadContexts(); + } catch (err) { + setContextError(extractErrorMessage(err, "Unable to create context.")); + } finally { + setContextActing(null); + } + }; + + const handleDeleteContext = async (id: string) => { + setContextActing(id); + setContextError(null); + try { + await getClient().deleteBrowserContext(id); + if (contextId === id) setContextId(""); + await loadContexts(); + } catch (err) { + setContextError(extractErrorMessage(err, "Unable to delete context.")); + } finally { + setContextActing(null); + } + }; + + // Content tools + const handleGetContent = async (type: "html" | "markdown" | "links" | "snapshot") => { + setContentLoading(type); + setContentError(null); + try { + let output = ""; + switch (type) { + case "html": { + const result = await getClient().getBrowserContent(); + output = result.html; + break; + } + case "markdown": { + const result = await getClient().getBrowserMarkdown(); + output = result.markdown; + break; + } + case "links": { + const result = await getClient().getBrowserLinks(); + output = result.links.map((l) => `${l.text} -> ${l.href}`).join("\n"); + break; + } + case "snapshot": { + const result = await getClient().getBrowserSnapshot(); + output = result.snapshot; + break; + } + } + setContentOutput(output); + } catch (err) { + setContentError(extractErrorMessage(err, `Unable to get ${type}.`)); + } finally { + setContentLoading(null); + } + }; + // Initial load useEffect(() => { void loadStatus(); @@ -264,13 +488,15 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { return () => clearInterval(interval); }, [status?.state, loadStatus]); - // Load tabs and console when browser becomes active + // Load tabs, console, network, and recordings when browser becomes active useEffect(() => { if (status?.state === "active") { void loadTabs(); void loadConsole(); + void loadNetwork(); + void loadRecordings(); } - }, [status?.state, loadTabs, loadConsole]); + }, [status?.state, loadTabs, loadConsole, loadNetwork, loadRecordings]); // Auto-refresh console every 3s when active useEffect(() => { @@ -279,6 +505,20 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { return () => clearInterval(interval); }, [status?.state, loadConsole]); + // Auto-refresh network every 3s when active + useEffect(() => { + if (status?.state !== "active") return; + const interval = setInterval(() => void loadNetwork(), 3000); + return () => clearInterval(interval); + }, [status?.state, loadNetwork]); + + // Poll recording list while a recording is active + useEffect(() => { + if (!activeRecording) return; + const interval = setInterval(() => void loadRecordings(), 3000); + return () => clearInterval(interval); + }, [activeRecording, loadRecordings]); + // Cleanup screenshot URL on unmount useEffect(() => { return () => revokeScreenshotUrl(); @@ -866,6 +1106,388 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => { )} )} + + {/* ========== Network Section ========== */} + {isActive && ( +
+
+ + + +
+ +
+ setNetworkUrlPattern(e.target.value)} + placeholder="Filter by URL pattern..." + style={{ width: "100%", fontSize: 11 }} + /> +
+ + {networkError && ( +
+ {networkError} +
+ )} + + {networkRequests.length > 0 ? ( +
+ {networkRequests.map((req, idx) => ( +
+ + {req.method} + + = 400 + ? "var(--danger, #ef4444)" + : req.status && req.status >= 300 + ? "var(--warning, #f59e0b)" + : "var(--success, #22c55e)", + }} + > + {req.status ?? "..."} + + {req.url} + {req.responseSize != null ? formatBytes(req.responseSize) : ""} + + {req.duration != null ? `${req.duration}ms` : ""} + +
+ ))} +
+ ) : ( +
No network requests captured.
+ )} +
+ )} + + {/* ========== Content Tools Section ========== */} + {isActive && ( +
+
+ + + Content Tools + +
+ +
+ {(["html", "markdown", "links", "snapshot"] as const).map((type) => ( + + ))} +
+ + {contentError && ( +
+ {contentError} +
+ )} + + {contentOutput ? ( +