import { ChevronDown, ChevronRight, Loader2, Play, RefreshCw, Skull, SquareTerminal, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { SandboxAgentError } from "sandbox-agent"; import type { ProcessInfo, SandboxAgent } from "sandbox-agent"; import GhosttyTerminal from "../processes/GhosttyTerminal"; const extractErrorMessage = (error: unknown, fallback: string): string => { if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail; if (error instanceof Error) return error.message; return fallback; }; const decodeBase64Utf8 = (value: string): string => { try { const bytes = Uint8Array.from(window.atob(value), (char) => char.charCodeAt(0)); return new TextDecoder().decode(bytes); } catch { return value; } }; const formatDateTime = (value: number | null | undefined): string => { if (!value) { return "Unknown"; } return new Date(value).toLocaleString(); }; const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean); const formatCommandSummary = (process: Pick): string => { return [process.command, ...process.args].join(" ").trim(); }; const canOpenTerminal = (process: ProcessInfo | null | undefined): boolean => { return Boolean(process && process.status === "running" && process.interactive && process.tty); }; const ProcessesTab = ({ getClient, }: { getClient: () => SandboxAgent; }) => { const [processes, setProcesses] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [command, setCommand] = useState(""); const [argsText, setArgsText] = useState(""); const [cwd, setCwd] = useState(""); const [interactive, setInteractive] = useState(true); const [tty, setTty] = useState(true); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [showCreateForm, setShowCreateForm] = useState(true); const [selectedProcessId, setSelectedProcessId] = useState(null); const [logsText, setLogsText] = useState(""); const [logsLoading, setLogsLoading] = useState(false); const [logsError, setLogsError] = useState(null); const [terminalOpen, setTerminalOpen] = useState(false); const [actingProcessId, setActingProcessId] = useState(null); const loadProcesses = useCallback(async (mode: "initial" | "refresh" = "initial") => { if (mode === "initial") { setLoading(true); } else { setRefreshing(true); } setError(null); try { const response = await getClient().listProcesses(); setProcesses(response.processes); setSelectedProcessId((current) => { if (!current) { return response.processes[0]?.id ?? null; } return response.processes.some((listedProcess) => listedProcess.id === current) ? current : response.processes[0]?.id ?? null; }); } catch (loadError) { setError(extractErrorMessage(loadError, "Unable to load processes.")); } finally { setLoading(false); setRefreshing(false); } }, [getClient]); const loadSelectedLogs = useCallback(async (process: ProcessInfo | null) => { if (!process) { setLogsText(""); setLogsError(null); return; } setLogsLoading(true); setLogsError(null); try { const response = await getClient().getProcessLogs(process.id, { stream: process.tty ? "pty" : "combined", tail: 200, }); const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join(""); setLogsText(text); } catch (loadError) { setLogsError(extractErrorMessage(loadError, "Unable to load process logs.")); setLogsText(""); } finally { setLogsLoading(false); } }, [getClient]); useEffect(() => { void loadProcesses(); }, [loadProcesses]); const selectedProcess = useMemo( () => processes.find((process) => process.id === selectedProcessId) ?? null, [processes, selectedProcessId] ); useEffect(() => { void loadSelectedLogs(selectedProcess); if (!canOpenTerminal(selectedProcess)) { setTerminalOpen(false); } }, [loadSelectedLogs, selectedProcess]); const handleCreateProcess = async () => { const trimmedCommand = command.trim(); if (!trimmedCommand) { setCreateError("Command is required."); return; } setCreating(true); setCreateError(null); try { const created = await getClient().createProcess({ command: trimmedCommand, args: parseArgs(argsText), cwd: cwd.trim() || undefined, interactive, tty, }); await loadProcesses("refresh"); setSelectedProcessId(created.id); setTerminalOpen(created.interactive && created.tty); setCommand(""); setArgsText(""); setCwd(""); setInteractive(true); setTty(true); } catch (createFailure) { setCreateError(extractErrorMessage(createFailure, "Unable to create process.")); } finally { setCreating(false); } }; const handleAction = async (processId: string, action: "stop" | "kill" | "delete") => { setActingProcessId(`${action}:${processId}`); setError(null); try { const client = getClient(); if (action === "stop") { await client.stopProcess(processId, { waitMs: 2_000 }); } else if (action === "kill") { await client.killProcess(processId, { waitMs: 2_000 }); } else { await client.deleteProcess(processId); } await loadProcesses("refresh"); } catch (actionError) { setError(extractErrorMessage(actionError, `Unable to ${action} process.`)); } finally { setActingProcessId(null); } }; return (
{/* Create form */}
{showCreateForm && (
{ setCommand(event.target.value); setCreateError(null); }} placeholder="bash" />
{ setCwd(event.target.value); setCreateError(null); }} placeholder="/workspace" />