feat: [US-028] - Add Browser tab - network, content tools, recording, contexts, diagnostics sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 06:49:18 -07:00
parent 589362ffc0
commit 65df2735f3

View file

@ -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<string>("all");
const consoleEndRef = useRef<HTMLDivElement>(null);
// Network
const [networkRequests, setNetworkRequests] = useState<BrowserNetworkRequest[]>([]);
const [networkLoading, setNetworkLoading] = useState(false);
const [networkError, setNetworkError] = useState<string | null>(null);
const [networkUrlPattern, setNetworkUrlPattern] = useState("");
// Content Tools
const [contentOutput, setContentOutput] = useState("");
const [contentLoading, setContentLoading] = useState<string | null>(null);
const [contentError, setContentError] = useState<string | null>(null);
// Recording
const [recordings, setRecordings] = useState<DesktopRecordingInfo[]>([]);
const [recordingLoading, setRecordingLoading] = useState(false);
const [recordingActing, setRecordingActing] = useState<"start" | "stop" | null>(null);
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingFps, setRecordingFps] = useState("30");
const [deletingRecordingId, setDeletingRecordingId] = useState<string | null>(null);
const [downloadingRecordingId, setDownloadingRecordingId] = useState<string | null>(null);
// Context management
const [contextName, setContextName] = useState("");
const [contextActing, setContextActing] = useState<string | null>(null);
const [contextError, setContextError] = useState<string | null>(null);
// Live view
const [liveViewActive, setLiveViewActive] = useState(false);
const [liveViewError, setLiveViewError] = useState<string | null>(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 }) => {
)}
</div>
)}
{/* ========== Network Section ========== */}
{isActive && (
<div className="card">
<div className="card-header">
<span className="card-title">
<Video size={14} style={{ marginRight: 6 }} />
Network
</span>
<button
className="button secondary small"
onClick={() => void loadNetwork()}
disabled={networkLoading}
style={{ padding: "4px 8px", fontSize: 11 }}
>
{networkLoading ? <Loader2 size={12} className="spinner-icon" /> : <RefreshCw size={12} />}
</button>
</div>
<div style={{ marginBottom: 8 }}>
<input
className="setup-input mono"
value={networkUrlPattern}
onChange={(e) => setNetworkUrlPattern(e.target.value)}
placeholder="Filter by URL pattern..."
style={{ width: "100%", fontSize: 11 }}
/>
</div>
{networkError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{networkError}
</div>
)}
{networkRequests.length > 0 ? (
<div
style={{
maxHeight: 280,
overflowY: "auto",
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--background, #0f172a)",
}}
>
{networkRequests.map((req, idx) => (
<div
key={`${req.timestamp}-${idx}`}
style={{
display: "flex",
gap: 8,
padding: "4px 8px",
fontSize: 11,
fontFamily: "monospace",
borderBottom: idx < networkRequests.length - 1 ? "1px solid var(--border)" : undefined,
alignItems: "center",
}}
>
<span
style={{
minWidth: 36,
fontWeight: 600,
color: "var(--info, #3b82f6)",
flexShrink: 0,
}}
>
{req.method}
</span>
<span
style={{
minWidth: 28,
flexShrink: 0,
color:
req.status && req.status >= 400
? "var(--danger, #ef4444)"
: req.status && req.status >= 300
? "var(--warning, #f59e0b)"
: "var(--success, #22c55e)",
}}
>
{req.status ?? "..."}
</span>
<span style={{ flex: 1, wordBreak: "break-all", color: "var(--foreground, #e2e8f0)" }}>{req.url}</span>
<span style={{ color: "var(--muted)", flexShrink: 0, fontSize: 10 }}>{req.responseSize != null ? formatBytes(req.responseSize) : ""}</span>
<span style={{ color: "var(--muted)", flexShrink: 0, fontSize: 10, minWidth: 40, textAlign: "right" }}>
{req.duration != null ? `${req.duration}ms` : ""}
</span>
</div>
))}
</div>
) : (
<div className="desktop-screenshot-empty">No network requests captured.</div>
)}
</div>
)}
{/* ========== Content Tools Section ========== */}
{isActive && (
<div className="card">
<div className="card-header">
<span className="card-title">
<Code size={14} style={{ marginRight: 6 }} />
Content Tools
</span>
</div>
<div style={{ display: "flex", gap: 6, marginBottom: 8, flexWrap: "wrap" }}>
{(["html", "markdown", "links", "snapshot"] as const).map((type) => (
<button
key={type}
className="button secondary small"
onClick={() => void handleGetContent(type)}
disabled={contentLoading !== null}
style={{ padding: "4px 10px", fontSize: 11, textTransform: "capitalize" }}
>
{contentLoading === type ? <Loader2 size={12} className="spinner-icon" style={{ marginRight: 4 }} /> : null}
Get {type === "html" ? "HTML" : type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
</div>
{contentError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{contentError}
</div>
)}
{contentOutput ? (
<textarea
className="mono"
readOnly
value={contentOutput}
style={{
width: "100%",
minHeight: 160,
maxHeight: 320,
resize: "vertical",
fontSize: 11,
padding: 8,
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--background, #0f172a)",
color: "var(--foreground, #e2e8f0)",
}}
/>
) : (
<div className="desktop-screenshot-empty">Click a button above to extract page content.</div>
)}
</div>
)}
{/* ========== Recording Section ========== */}
<div className="card">
<div className="card-header">
<span className="card-title">
<Circle size={14} style={{ marginRight: 6, fill: activeRecording ? "#ff3b30" : "none" }} />
Recording
</span>
{activeRecording && <span className="pill danger">Recording</span>}
</div>
{recordingError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{recordingError}
</div>
)}
{!isActive && <div className="desktop-screenshot-empty">Start the browser runtime to enable recording.</div>}
{isActive && (
<>
<div className="desktop-start-controls" style={{ gridTemplateColumns: "1fr" }}>
<div className="desktop-input-group">
<label className="label">FPS</label>
<input
className="setup-input mono"
value={recordingFps}
onChange={(e) => setRecordingFps(e.target.value)}
inputMode="numeric"
style={{ maxWidth: 80 }}
disabled={!!activeRecording}
/>
</div>
</div>
<div className="card-actions">
{!activeRecording ? (
<button className="button danger small" onClick={() => void handleStartRecording()} disabled={recordingActing === "start"}>
{recordingActing === "start" ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Circle size={14} className="button-icon" style={{ fill: "#ff3b30" }} />
)}
Start Recording
</button>
) : (
<button className="button secondary small" onClick={() => void handleStopRecording()} disabled={recordingActing === "stop"}>
{recordingActing === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
Stop Recording
</button>
)}
<button className="button secondary small" onClick={() => void loadRecordings()} disabled={recordingLoading}>
<RefreshCw className={`button-icon ${recordingLoading ? "spinner-icon" : ""}`} />
Refresh
</button>
</div>
{recordings.length > 0 && (
<div className="desktop-process-list" style={{ marginTop: 12 }}>
{recordings.map((rec) => (
<div key={rec.id} className="desktop-process-item">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<strong className="mono" style={{ fontSize: 12 }}>
{rec.fileName}
</strong>
<span
className={`pill ${rec.status === "recording" ? "danger" : rec.status === "completed" ? "success" : "warning"}`}
style={{ marginLeft: 8 }}
>
{rec.status}
</span>
</div>
{rec.status === "completed" && (
<div style={{ display: "flex", gap: 4 }}>
<button
className="button ghost small"
title="Download"
onClick={() => void handleDownloadRecording(rec.id, rec.fileName)}
disabled={downloadingRecordingId === rec.id}
style={{ padding: "4px 6px" }}
>
{downloadingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Download size={14} />}
</button>
<button
className="button ghost small"
title="Delete"
onClick={() => void handleDeleteRecording(rec.id)}
disabled={deletingRecordingId === rec.id}
style={{ padding: "4px 6px", color: "var(--danger)" }}
>
{deletingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Trash2 size={14} />}
</button>
</div>
)}
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>
{formatBytes(rec.bytes)}
{" \u00b7 "}
{formatDuration(rec.startedAt, rec.endedAt)}
{" \u00b7 "}
{formatStartedAt(rec.startedAt)}
</div>
</div>
))}
</div>
)}
{recordings.length === 0 && !recordingLoading && (
<div className="desktop-screenshot-empty" style={{ marginTop: 8 }}>
No recordings yet. Click "Start Recording" to begin.
</div>
)}
</>
)}
</div>
{/* ========== Contexts Section ========== */}
<div className="card">
<div className="card-header">
<span className="card-title">
<Database size={14} style={{ marginRight: 6 }} />
Browser Contexts
</span>
<button className="button secondary small" onClick={() => void loadContexts()} style={{ padding: "4px 8px", fontSize: 11 }}>
<RefreshCw size={12} />
</button>
</div>
{contextError && (
<div className="banner error" style={{ marginBottom: 8 }}>
{contextError}
</div>
)}
{contexts.length > 0 ? (
<div className="desktop-process-list">
{contexts.map((ctx) => (
<div key={ctx.id} className={`desktop-process-item ${contextId === ctx.id ? "desktop-window-focused" : ""}`}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ minWidth: 0, flex: 1 }}>
<strong style={{ fontSize: 12 }}>{ctx.name}</strong>
{contextId === ctx.id && (
<span className="pill success" style={{ marginLeft: 8 }}>
selected
</span>
)}
<div className="mono" style={{ fontSize: 11, color: "var(--muted)", marginTop: 2 }}>
{ctx.id.slice(0, 12)} {ctx.sizeBytes != null ? ` \u00b7 ${formatBytes(ctx.sizeBytes)}` : ""} {" \u00b7 "}{" "}
{new Date(ctx.createdAt).toLocaleDateString()}
</div>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0, marginLeft: 8 }}>
{contextId !== ctx.id && (
<button
className="button ghost small"
title="Use this context"
onClick={() => setContextId(ctx.id)}
style={{ padding: "4px 8px", fontSize: 11 }}
>
Use
</button>
)}
<button
className="button ghost small"
title="Delete"
onClick={() => void handleDeleteContext(ctx.id)}
disabled={contextActing === ctx.id}
style={{ padding: "4px 6px", color: "var(--danger)" }}
>
{contextActing === ctx.id ? <Loader2 size={14} className="spinner-icon" /> : <Trash2 size={14} />}
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="desktop-screenshot-empty">No browser contexts. Using ephemeral profile.</div>
)}
<div style={{ marginTop: 10, display: "flex", gap: 6, alignItems: "center" }}>
<input
className="setup-input mono"
value={contextName}
onChange={(e) => setContextName(e.target.value)}
placeholder="Context name..."
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateContext();
}}
style={{ flex: 1, fontSize: 11 }}
/>
<button
className="button secondary small"
onClick={() => void handleCreateContext()}
disabled={contextActing === "create" || !contextName.trim()}
style={{ padding: "4px 10px", fontSize: 11 }}
>
{contextActing === "create" ? <Loader2 size={12} className="spinner-icon" /> : <Plus size={12} style={{ marginRight: 4 }} />}
Create
</button>
</div>
</div>
{/* ========== Diagnostics Section ========== */}
{(status?.lastError || (status?.processes?.length ?? 0) > 0) && (
<div className="card">
<div className="card-header">
<span className="card-title">Diagnostics</span>
</div>
{status?.lastError && (
<div className="desktop-diagnostic-block">
<div className="card-meta">Last error</div>
<div className="mono">{status.lastError.code}</div>
<div>{status.lastError.message}</div>
</div>
)}
{status?.processes && status.processes.length > 0 && (
<div className="desktop-diagnostic-block">
<div className="card-meta">Processes</div>
<div className="desktop-process-list">
{status.processes.map((process) => (
<div key={`${process.name}-${process.pid ?? "none"}`} className="desktop-process-item">
<div>
<strong>{process.name}</strong>
<span className={`pill ${process.running ? "success" : "danger"}`} style={{ marginLeft: 8 }}>
{process.running ? "running" : "stopped"}
</span>
</div>
<div className="mono">{process.pid ? `pid ${process.pid}` : "no pid"}</div>
{process.logPath && <div className="mono">{process.logPath}</div>}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};