mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 13:03:46 +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 { 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue