mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 22:02:42 +00:00
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:
parent
589362ffc0
commit
65df2735f3
1 changed files with 626 additions and 4 deletions
|
|
@ -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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { SandboxAgentError } from "sandbox-agent";
|
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 { DesktopViewer } from "@sandbox-agent/react";
|
||||||
import type { BrowserViewerClient } 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 CONSOLE_LEVELS = ["all", "log", "warn", "error", "info"] as const;
|
||||||
|
|
||||||
const consoleLevelColor = (level: string): string => {
|
const consoleLevelColor = (level: string): string => {
|
||||||
|
|
@ -93,6 +134,31 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
const [consoleLevel, setConsoleLevel] = useState<string>("all");
|
const [consoleLevel, setConsoleLevel] = useState<string>("all");
|
||||||
const consoleEndRef = useRef<HTMLDivElement>(null);
|
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
|
// Live view
|
||||||
const [liveViewActive, setLiveViewActive] = useState(false);
|
const [liveViewActive, setLiveViewActive] = useState(false);
|
||||||
const [liveViewError, setLiveViewError] = useState<string | null>(null);
|
const [liveViewError, setLiveViewError] = useState<string | null>(null);
|
||||||
|
|
@ -251,6 +317,164 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
}
|
}
|
||||||
}, [getClient, consoleLevel]);
|
}, [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
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStatus();
|
void loadStatus();
|
||||||
|
|
@ -264,13 +488,15 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [status?.state, loadStatus]);
|
}, [status?.state, loadStatus]);
|
||||||
|
|
||||||
// Load tabs and console when browser becomes active
|
// Load tabs, console, network, and recordings when browser becomes active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.state === "active") {
|
if (status?.state === "active") {
|
||||||
void loadTabs();
|
void loadTabs();
|
||||||
void loadConsole();
|
void loadConsole();
|
||||||
|
void loadNetwork();
|
||||||
|
void loadRecordings();
|
||||||
}
|
}
|
||||||
}, [status?.state, loadTabs, loadConsole]);
|
}, [status?.state, loadTabs, loadConsole, loadNetwork, loadRecordings]);
|
||||||
|
|
||||||
// Auto-refresh console every 3s when active
|
// Auto-refresh console every 3s when active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -279,6 +505,20 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [status?.state, loadConsole]);
|
}, [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
|
// Cleanup screenshot URL on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => revokeScreenshotUrl();
|
return () => revokeScreenshotUrl();
|
||||||
|
|
@ -866,6 +1106,388 @@ const BrowserTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue