import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SandboxAgentError, SandboxAgent, type AgentInfo, type AgentModeInfo, type PermissionEventData, type QuestionEventData, type SessionInfo, type UniversalEvent, type UniversalItem } from "sandbox-agent"; import ChatPanel from "./components/chat/ChatPanel"; import type { TimelineEntry } from "./components/chat/types"; import ConnectScreen from "./components/ConnectScreen"; import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel"; import SessionSidebar from "./components/SessionSidebar"; import type { RequestLog } from "./types/requestLog"; import { buildCurl } from "./utils/http"; const defaultAgents = ["claude", "codex", "opencode", "amp", "mock"]; type ItemEventData = { item: UniversalItem; }; type ItemDeltaEventData = { item_id: string; native_item_id?: string | null; delta: string; }; const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => { return { item_id: itemId, native_item_id: nativeItemId ?? null, parent_id: null, kind: "message", role: null, content: [], status: "in_progress" } as UniversalItem; }; const getDefaultEndpoint = () => { if (typeof window === "undefined") return "http://127.0.0.1:2468"; const { origin, protocol } = window.location; if (!origin || origin === "null" || protocol === "file:") { return "http://127.0.0.1:2468"; } return origin; }; const getInitialConnection = () => { if (typeof window === "undefined") { return { endpoint: "http://127.0.0.1:2468", token: "" }; } const params = new URLSearchParams(window.location.search); const urlParam = params.get("url")?.trim(); const tokenParam = params.get("token") ?? ""; return { endpoint: urlParam && urlParam.length > 0 ? urlParam : getDefaultEndpoint(), token: tokenParam }; }; export default function App() { const issueTrackerUrl = "https://github.com/rivet-dev/sandbox-agent/issues/new"; const initialConnectionRef = useRef(getInitialConnection()); const [endpoint, setEndpoint] = useState(initialConnectionRef.current.endpoint); const [token, setToken] = useState(initialConnectionRef.current.token); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const [agents, setAgents] = useState([]); const [modesByAgent, setModesByAgent] = useState>({}); const [sessions, setSessions] = useState([]); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState(null); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsError, setSessionsError] = useState(null); const [modesLoadingByAgent, setModesLoadingByAgent] = useState>({}); const [modesErrorByAgent, setModesErrorByAgent] = useState>({}); const [agentId, setAgentId] = useState("claude"); const [agentMode, setAgentMode] = useState(""); const [permissionMode, setPermissionMode] = useState("default"); const [model, setModel] = useState(""); const [variant, setVariant] = useState(""); const [sessionId, setSessionId] = useState(""); const [sessionError, setSessionError] = useState(null); const [message, setMessage] = useState(""); const [events, setEvents] = useState([]); const [offset, setOffset] = useState(0); const offsetRef = useRef(0); const [eventsLoading, setEventsLoading] = useState(false); const [polling, setPolling] = useState(false); const pollTimerRef = useRef(null); const [turnStreaming, setTurnStreaming] = useState(false); const [streamMode, setStreamMode] = useState<"poll" | "sse" | "turn">("sse"); const [eventError, setEventError] = useState(null); const [questionSelections, setQuestionSelections] = useState>({}); const [questionStatus, setQuestionStatus] = useState>({}); const [permissionStatus, setPermissionStatus] = useState>({}); const [requestLog, setRequestLog] = useState([]); const logIdRef = useRef(1); const [copiedLogId, setCopiedLogId] = useState(null); const [debugTab, setDebugTab] = useState("events"); const messagesEndRef = useRef(null); const clientRef = useRef(null); const sseAbortRef = useRef(null); const turnAbortRef = useRef(null); const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { const next = [entry, ...prev]; return next.slice(0, 200); }); }, []); const createClient = useCallback(async () => { const fetchWithLog: typeof fetch = async (input, init) => { const method = init?.method ?? "GET"; const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const bodyText = typeof init?.body === "string" ? init.body : undefined; const curl = buildCurl(method, url, bodyText, token); const logId = logIdRef.current++; const entry: RequestLog = { id: logId, method, url, body: bodyText, time: new Date().toLocaleTimeString(), curl }; let logged = false; try { const response = await fetch(input, init); logRequest({ ...entry, status: response.status }); logged = true; return response; } catch (error) { const messageText = error instanceof Error ? error.message : "Request failed"; if (!logged) { logRequest({ ...entry, status: 0, error: messageText }); } throw error; } }; const client = await SandboxAgent.connect({ baseUrl: endpoint, token: token || undefined, fetch: fetchWithLog }); clientRef.current = client; return client; }, [endpoint, token, logRequest]); const getClient = useCallback((): SandboxAgent => { if (!clientRef.current) { throw new Error("Not connected"); } return clientRef.current; }, []); const getErrorMessage = (error: unknown, fallback: string) => { if (error instanceof SandboxAgentError) { return error.problem?.detail ?? error.problem?.title ?? error.message; } return error instanceof Error ? error.message : fallback; }; const connectToDaemon = async (reportError: boolean) => { setConnecting(true); if (reportError) { setConnectError(null); } try { const client = await createClient(); await client.getHealth(); setConnected(true); await refreshAgents(); await fetchSessions(); if (reportError) { setConnectError(null); } } catch (error) { if (reportError) { const messageText = getErrorMessage(error, "Unable to connect"); setConnectError(messageText); } setConnected(false); clientRef.current = null; } finally { setConnecting(false); } }; const connect = () => connectToDaemon(true); const disconnect = () => { setConnected(false); clientRef.current = null; setSessionError(null); setEvents([]); setOffset(0); offsetRef.current = 0; setEventError(null); stopPolling(); stopSse(); stopTurnStream(); setAgents([]); setSessions([]); setAgentsLoading(false); setSessionsLoading(false); setAgentsError(null); setSessionsError(null); }; const refreshAgents = async () => { setAgentsLoading(true); setAgentsError(null); try { const data = await getClient().listAgents(); const agentList = data.agents ?? []; setAgents(agentList); for (const agent of agentList) { if (agent.installed) { loadModes(agent.id); } } } catch (error) { setAgentsError(getErrorMessage(error, "Unable to refresh agents")); } finally { setAgentsLoading(false); } }; const fetchSessions = async () => { setSessionsLoading(true); setSessionsError(null); try { const data = await getClient().listSessions(); const sessionList = data.sessions ?? []; setSessions(sessionList); } catch { setSessionsError("Unable to load sessions."); } finally { setSessionsLoading(false); } }; const installAgent = async (targetId: string, reinstall: boolean) => { try { await getClient().installAgent(targetId, { reinstall }); await refreshAgents(); } catch (error) { setConnectError(getErrorMessage(error, "Install failed")); } }; const loadModes = async (targetId: string) => { setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: true })); setModesErrorByAgent((prev) => ({ ...prev, [targetId]: null })); try { const data = await getClient().getAgentModes(targetId); const modes = data.modes ?? []; setModesByAgent((prev) => ({ ...prev, [targetId]: modes })); } catch { setModesErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load modes." })); } finally { setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: false })); } }; const sendMessage = async () => { const prompt = message.trim(); if (!prompt || !sessionId || turnStreaming) return; setSessionError(null); setMessage(""); if (streamMode === "turn") { await startTurnStream(prompt); return; } try { await getClient().postMessage(sessionId, { message: prompt }); if (!polling) { if (streamMode === "poll") { startPolling(); } else { startSse(); } } } catch (error) { setSessionError(getErrorMessage(error, "Unable to send message")); } }; const selectSession = (session: SessionInfo) => { stopTurnStream(); setSessionId(session.sessionId); setAgentId(session.agent); setAgentMode(session.agentMode); setPermissionMode(session.permissionMode); setModel(session.model ?? ""); setVariant(session.variant ?? ""); setEvents([]); setOffset(0); offsetRef.current = 0; setSessionError(null); }; const createNewSession = async (nextAgentId?: string) => { stopTurnStream(); const selectedAgent = nextAgentId ?? agentId; if (nextAgentId) { setAgentId(nextAgentId); } const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; for (let i = 0; i < 8; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } setSessionId(id); setEvents([]); setOffset(0); offsetRef.current = 0; setSessionError(null); try { const body: { agent: string; agentMode?: string; permissionMode?: string; model?: string; variant?: string; } = { agent: selectedAgent }; if (agentMode) body.agentMode = agentMode; if (permissionMode) body.permissionMode = permissionMode; if (model) body.model = model; if (variant) body.variant = variant; await getClient().createSession(id, body); await fetchSessions(); } catch (error) { setSessionError(getErrorMessage(error, "Unable to create session")); } }; const appendEvents = useCallback((incoming: UniversalEvent[]) => { if (!incoming.length) return; setEvents((prev) => [...prev, ...incoming]); const lastSeq = incoming[incoming.length - 1]?.sequence ?? offsetRef.current; offsetRef.current = lastSeq; setOffset(lastSeq); }, []); const fetchEvents = useCallback(async () => { if (!sessionId) return; setEventsLoading(true); try { const response = await getClient().getEvents(sessionId, { offset: offsetRef.current, limit: 200 }); const newEvents = response.events ?? []; appendEvents(newEvents); setEventError(null); } catch (error) { setEventError(getErrorMessage(error, "Unable to fetch events")); } finally { setEventsLoading(false); } }, [appendEvents, getClient, sessionId]); const startPolling = () => { stopSse(); if (pollTimerRef.current) return; setPolling(true); fetchEvents(); pollTimerRef.current = window.setInterval(fetchEvents, 500); }; const stopPolling = () => { if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } setPolling(false); }; const startSse = () => { stopPolling(); if (sseAbortRef.current) return; if (!sessionId) { setEventError("Select or create a session first."); return; } setEventError(null); setPolling(true); const controller = new AbortController(); sseAbortRef.current = controller; const start = async () => { try { for await (const event of getClient().streamEvents( sessionId, { offset: offsetRef.current }, controller.signal )) { appendEvents([event]); } } catch (error) { if (controller.signal.aborted) { return; } setEventError(getErrorMessage(error, "SSE connection error. Falling back to polling.")); stopSse(); startPolling(); } finally { if (sseAbortRef.current === controller) { sseAbortRef.current = null; setPolling(false); } } }; void start(); }; const stopSse = () => { if (sseAbortRef.current) { sseAbortRef.current.abort(); sseAbortRef.current = null; } setPolling(false); }; const startTurnStream = async (prompt: string) => { stopPolling(); stopSse(); if (turnAbortRef.current) return; if (!sessionId) { setEventError("Select or create a session first."); return; } setEventError(null); setTurnStreaming(true); const controller = new AbortController(); turnAbortRef.current = controller; try { for await (const event of getClient().streamTurn( sessionId, { message: prompt }, undefined, controller.signal )) { appendEvents([event]); } } catch (error) { if (controller.signal.aborted) { return; } setEventError(getErrorMessage(error, "Turn stream error.")); } finally { if (turnAbortRef.current === controller) { turnAbortRef.current = null; setTurnStreaming(false); } } }; const stopTurnStream = () => { if (turnAbortRef.current) { turnAbortRef.current.abort(); turnAbortRef.current = null; } setTurnStreaming(false); }; const resetEvents = () => { setEvents([]); setOffset(0); offsetRef.current = 0; }; const handleCopy = async (entry: RequestLog) => { try { await navigator.clipboard.writeText(entry.curl); setCopiedLogId(entry.id); window.setTimeout(() => setCopiedLogId(null), 1500); } catch { setCopiedLogId(null); } }; const selectQuestionOption = (requestId: string, optionLabel: string) => { setQuestionSelections((prev) => ({ ...prev, [requestId]: [[optionLabel]] })); }; const answerQuestion = async (request: QuestionEventData) => { const answers = questionSelections[request.question_id] ?? []; try { await getClient().replyQuestion(sessionId, request.question_id, { answers }); setQuestionStatus((prev) => ({ ...prev, [request.question_id]: "replied" })); } catch (error) { setEventError(getErrorMessage(error, "Unable to reply")); } }; const rejectQuestion = async (requestId: string) => { try { await getClient().rejectQuestion(sessionId, requestId); setQuestionStatus((prev) => ({ ...prev, [requestId]: "rejected" })); } catch (error) { setEventError(getErrorMessage(error, "Unable to reject")); } }; const replyPermission = async (requestId: string, reply: "once" | "always" | "reject") => { try { await getClient().replyPermission(sessionId, requestId, { reply }); setPermissionStatus((prev) => ({ ...prev, [requestId]: "replied" })); } catch (error) { setEventError(getErrorMessage(error, "Unable to reply")); } }; const questionRequests = useMemo(() => { const latestById = new Map(); for (const event of events) { if (event.type === "question.requested" || event.type === "question.resolved") { const data = event.data as QuestionEventData; latestById.set(data.question_id, data); } } return Array.from(latestById.values()).filter( (request) => request.status === "requested" && !questionStatus[request.question_id] ); }, [events, questionStatus]); const permissionRequests = useMemo(() => { const latestById = new Map(); for (const event of events) { if (event.type === "permission.requested" || event.type === "permission.resolved") { const data = event.data as PermissionEventData; latestById.set(data.permission_id, data); } } return Array.from(latestById.values()).filter( (request) => request.status === "requested" && !permissionStatus[request.permission_id] ); }, [events, permissionStatus]); const transcriptEntries = useMemo(() => { const entries: TimelineEntry[] = []; const itemMap = new Map(); const upsertItemEntry = (item: UniversalItem, time: string) => { let entry = itemMap.get(item.item_id); if (!entry) { entry = { id: item.item_id, kind: "item", time, item, deltaText: "" }; itemMap.set(item.item_id, entry); entries.push(entry); } else { entry.item = item; entry.time = time; } return entry; }; for (const event of events) { switch (event.type) { case "item.started": { const data = event.data as ItemEventData; upsertItemEntry(data.item, event.time); break; } case "item.delta": { const data = event.data as ItemDeltaEventData; const stub = buildStubItem(data.item_id, data.native_item_id); const entry = upsertItemEntry(stub, event.time); entry.deltaText = `${entry.deltaText ?? ""}${data.delta ?? ""}`; break; } case "item.completed": { const data = event.data as ItemEventData; const entry = upsertItemEntry(data.item, event.time); entry.deltaText = ""; break; } case "error": { const data = event.data as { message: string; code?: string | null }; entries.push({ id: event.event_id, kind: "meta", time: event.time, meta: { title: data.code ? `Error - ${data.code}` : "Error", detail: data.message, severity: "error" } }); break; } case "agent.unparsed": { const data = event.data as { error: string; location: string }; entries.push({ id: event.event_id, kind: "meta", time: event.time, meta: { title: "Agent parse failure", detail: `${data.location}: ${data.error}`, severity: "error" } }); break; } case "session.started": { entries.push({ id: event.event_id, kind: "meta", time: event.time, meta: { title: "Session started", severity: "info" } }); break; } case "session.ended": { const data = event.data as { reason: string; terminated_by: string }; entries.push({ id: event.event_id, kind: "meta", time: event.time, meta: { title: "Session ended", detail: `${data.reason} - ${data.terminated_by}`, severity: "info" } }); break; } default: break; } } return entries; }, [events]); useEffect(() => { return () => { stopPolling(); stopSse(); stopTurnStream(); }; }, []); useEffect(() => { let active = true; const attempt = async () => { await connectToDaemon(false); }; attempt().catch(() => { if (!active) return; setConnecting(false); }); return () => { active = false; }; }, []); useEffect(() => { if (!connected) return; refreshAgents(); }, [connected]); useEffect(() => { if (!connected || !sessionId || polling) return; if (streamMode === "turn") return; const hasSession = sessions.some((session) => session.sessionId === sessionId); if (!hasSession) return; if (streamMode === "poll") { startPolling(); } else { startSse(); } }, [connected, sessionId, polling, streamMode, sessions]); useEffect(() => { if (streamMode === "turn") { stopPolling(); stopSse(); } else if (turnStreaming) { stopTurnStream(); } }, [streamMode, turnStreaming]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [transcriptEntries]); useEffect(() => { if (connected && agentId && !modesByAgent[agentId]) { loadModes(agentId); } }, [connected, agentId]); useEffect(() => { const modes = modesByAgent[agentId]; if (modes && modes.length > 0 && !agentMode) { setAgentMode(modes[0].id); } }, [modesByAgent, agentId]); const availableAgents = agents.length ? agents.map((agent) => agent.id) : defaultAgents; const currentAgent = agents.find((agent) => agent.id === agentId); const activeModes = modesByAgent[agentId] ?? []; const modesLoading = modesLoadingByAgent[agentId] ?? false; const modesError = modesErrorByAgent[agentId] ?? null; const agentDisplayNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", amp: "Amp", mock: "Mock" }; const agentLabel = agentDisplayNames[agentId] ?? agentId; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }; const toggleStream = () => { if (streamMode === "turn") { return; } if (polling) { if (streamMode === "poll") { stopPolling(); } else { stopSse(); } } else if (streamMode === "poll") { startPolling(); } else { startSse(); } }; if (!connected) { return ( ); } return (
SA
Sandbox Agent
Report Bug {endpoint}
setRequestLog([])} onCopyRequestLog={handleCopy} agents={agents} defaultAgents={defaultAgents} modesByAgent={modesByAgent} onRefreshAgents={refreshAgents} onInstallAgent={installAgent} agentsLoading={agentsLoading} agentsError={agentsError} />
); }