import type { TranscriptEntry } from "@sandbox-agent/react"; import { BookOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SandboxAgent, SandboxAgentError, type AgentInfo, type SessionEvent, type Session, type SessionPermissionRequest, type PermissionReply, InMemorySessionPersistDriver, type SessionPersistDriver, } from "sandbox-agent"; type ConfigSelectOption = { value: string; name: string; description?: string }; type ConfigOption = { id: string; name: string; category?: string; type?: string; currentValue?: string; options?: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>; }; type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; import ChatPanel from "./components/chat/ChatPanel"; 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 flattenSelectOptions = (options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>): ConfigSelectOption[] => { if (options.length === 0) return []; if ("value" in options[0]) return options as ConfigSelectOption[]; return (options as Array<{ options: ConfigSelectOption[] }>).flatMap((g) => g.options); }; const logoUrl = `${import.meta.env.BASE_URL}logos/sandboxagent.svg`; const defaultAgents = ["claude", "codex", "opencode", "amp", "pi", "cursor"]; type ErrorToast = { id: number; message: string; }; type SessionListItem = { sessionId: string; agent: string; ended: boolean; archived: boolean; }; const ERROR_TOAST_MS = 6000; const MAX_ERROR_TOASTS = 3; const CREATE_SESSION_SLOW_WARNING_MS = 90_000; const HTTP_ERROR_EVENT = "inspector-http-error"; const ARCHIVED_SESSIONS_KEY = "sandbox-agent-inspector-archived-sessions"; const SESSION_MODELS_KEY = "sandbox-agent-inspector-session-models"; const DEFAULT_ENDPOINT = "http://localhost:2468"; const getCurrentOriginEndpoint = () => { if (typeof window === "undefined") { return null; } return window.location.origin; }; const getErrorMessage = (error: unknown, fallback: string) => { if (error instanceof SandboxAgentError) { return error.problem?.detail ?? error.problem?.title ?? error.message; } if (error instanceof Error) { // ACP RequestError may carry a data object with a hint or details field. const data = (error as { data?: Record }).data; if (data && typeof data === "object") { const hint = typeof data.hint === "string" ? data.hint : null; const details = typeof data.details === "string" ? data.details : null; if (hint) return hint; if (details) return details; } return error.message; } return fallback; }; const getHttpErrorMessage = (status: number, statusText: string, responseBody: string) => { const base = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`; const body = responseBody.trim(); if (!body) { return base; } try { const parsed = JSON.parse(body); if (parsed && typeof parsed === "object") { const detail = (parsed as { detail?: unknown }).detail; if (typeof detail === "string" && detail.trim()) { return detail; } const title = (parsed as { title?: unknown }).title; if (typeof title === "string" && title.trim()) { return title; } const message = (parsed as { message?: unknown }).message; if (typeof message === "string" && message.trim()) { return message; } } } catch { // Ignore parse failures and fall through to body text. } const clippedBody = body.length > 240 ? `${body.slice(0, 240)}...` : body; return `${base}: ${clippedBody}`; }; const shouldIgnoreGlobalError = (value: unknown): boolean => { const name = value instanceof Error ? value.name : ""; const message = (() => { if (typeof value === "string") return value; if (value instanceof Error) return value.message; if (value && typeof value === "object" && "message" in value && typeof (value as { message?: unknown }).message === "string") { return (value as { message: string }).message; } return ""; })().toLowerCase(); if (name === "AbortError") return true; if (!message) return false; return ( message.includes("aborterror") || message.includes("the operation was aborted") || message.includes("signal is aborted") || message.includes("acp client is closed") || (message.includes("method not found") && message.includes("unstable/set_session_model")) || message.includes("resizeobserver loop limit exceeded") || message.includes("resizeobserver loop completed with undelivered notifications") ); }; const getSessionIdFromPath = (): string => { const basePath = import.meta.env.BASE_URL; const path = window.location.pathname; const relative = path.startsWith(basePath) ? path.slice(basePath.length) : path; const match = relative.match(/^sessions\/(.+)/); return match ? match[1] : ""; }; const getArchivedSessionIds = (): Set => { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(ARCHIVED_SESSIONS_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); } catch { return new Set(); } }; const archiveSessionId = (id: string): void => { if (typeof window === "undefined" || !id) return; const archived = getArchivedSessionIds(); archived.add(id); window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); }; const unarchiveSessionId = (id: string): void => { if (typeof window === "undefined" || !id) return; const archived = getArchivedSessionIds(); if (!archived.delete(id)) return; window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); }; const getPersistedSessionModels = (): Record => { if (typeof window === "undefined") return {}; try { const raw = window.localStorage.getItem(SESSION_MODELS_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; return Object.fromEntries( Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0), ); } catch { return {}; } }; const updateSessionPath = (id: string) => { const basePath = import.meta.env.BASE_URL; const params = window.location.search; const newPath = id ? `${basePath}sessions/${id}${params}` : `${basePath}${params}`; if (window.location.pathname + window.location.search !== newPath) { window.history.replaceState(null, "", newPath); } }; const areEventsEqualById = (a: SessionEvent[], b: SessionEvent[]): boolean => { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) { if (a[i]?.id !== b[i]?.id) return false; } return true; }; const getInitialConnection = () => { if (typeof window === "undefined") { return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record, hasUrlParam: false }; } const params = new URLSearchParams(window.location.search); const urlParam = params.get("url")?.trim(); const tokenParam = params.get("token") ?? ""; const headersParam = params.get("headers"); let headers: Record = {}; if (headersParam) { try { headers = JSON.parse(headersParam); } catch { console.warn("Invalid headers query param, ignoring"); } } const hasUrlParam = urlParam != null && urlParam.length > 0; const defaultEndpoint = import.meta.env.DEV ? DEFAULT_ENDPOINT : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT); return { endpoint: hasUrlParam ? urlParam : defaultEndpoint, token: tokenParam, headers, hasUrlParam, }; }; const agentDisplayNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", amp: "Amp", pi: "Pi", cursor: "Cursor", }; export default function App() { const issueTrackerUrl = "https://github.com/rivet-dev/sandbox-agent/issues"; const docsUrl = "https://sandboxagent.dev/docs"; const discordUrl = "https://rivet.dev/discord"; const initialConnectionRef = useRef(getInitialConnection()); const [endpoint, setEndpoint] = useState(initialConnectionRef.current.endpoint); const [token, setToken] = useState(initialConnectionRef.current.token); const [extraHeaders] = useState(initialConnectionRef.current.headers); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const [agents, setAgents] = 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 [agentId, setAgentId] = useState("claude"); const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState(null); const [sessionModelById, setSessionModelById] = useState>(() => getPersistedSessionModels()); const [message, setMessage] = useState(""); const [events, setEvents] = useState([]); const [sendingSessionId, setSendingSessionId] = useState(null); const [historyLoadingSessionId, setHistoryLoadingSessionId] = useState(null); const [requestLog, setRequestLog] = useState([]); const logIdRef = useRef(1); const [copiedLogId, setCopiedLogId] = useState(null); const [errorToasts, setErrorToasts] = useState([]); const toastIdRef = useRef(1); const toastTimeoutsRef = useRef>(new Map()); const toastExpiryRef = useRef>(new Map()); const toastRemainingMsRef = useRef>(new Map()); const [debugTab, setDebugTab] = useState("events"); const [highlightedEventId, setHighlightedEventId] = useState(null); const [debugPanelCollapsed, setDebugPanelCollapsed] = useState(false); const messagesEndRef = useRef(null); const clientRef = useRef(null); const activeSessionRef = useRef(null); const eventUnsubRef = useRef<(() => void) | null>(null); const permissionUnsubRef = useRef<(() => void) | null>(null); const pendingPermissionsRef = useRef>(new Map()); const permissionToolCallToIdRef = useRef>(new Map()); const [pendingPermissionIds, setPendingPermissionIds] = useState>(new Set()); const [resolvedPermissions, setResolvedPermissions] = useState>(new Map()); const sessionEventsCacheRef = useRef>(new Map()); const selectedSessionIdRef = useRef(sessionId); const resumeInFlightSessionIdRef = useRef(null); const subscriptionGenerationRef = useRef(0); const reconnectingAfterCreateFailureRef = useRef(false); const creatingSessionRef = useRef(false); const createNoiseIgnoreUntilRef = useRef(0); useEffect(() => { selectedSessionIdRef.current = sessionId; if (!sessionId) { setHistoryLoadingSessionId(null); } }, [sessionId]); const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { const next = [entry, ...prev]; return next.slice(0, 200); }); }, []); const createClient = useCallback( async (overrideEndpoint?: string) => { const targetEndpoint = overrideEndpoint ?? endpoint; 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 headers: Record = {}; if (init?.headers) { const h = new Headers(init.headers as HeadersInit); h.forEach((v, k) => { headers[k] = v; }); } const entry: RequestLog = { id: logId, method, url, headers, body: bodyText, time: new Date().toLocaleTimeString(), curl, }; let logged = false; const fetchInit = { ...init, targetAddressSpace: "loopback", }; try { const response = await fetch(input, fetchInit); const acceptsStream = headers["accept"]?.includes("text/event-stream"); if (acceptsStream) { const ct = response.headers.get("content-type") ?? ""; if (!ct.includes("text/event-stream")) { throw new Error(`Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})`); } logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" }); logged = true; return response; } const clone = response.clone(); const responseBody = await clone.text().catch(() => ""); logRequest({ ...entry, status: response.status, responseBody }); if (!response.ok && response.status >= 500) { const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody); window.dispatchEvent(new CustomEvent(HTTP_ERROR_EVENT, { detail: messageText })); } 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; } }; let persist: SessionPersistDriver; try { persist = new IndexedDbSessionPersistDriver({ databaseName: "sandbox-agent-inspector", }); } catch { persist = new InMemorySessionPersistDriver({ maxSessions: 512, maxEventsPerSession: 5_000, }); } const client = await SandboxAgent.connect({ baseUrl: targetEndpoint, token: token || undefined, fetch: fetchWithLog, headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined, persist, }); clientRef.current = client; return client; }, [endpoint, token, extraHeaders, logRequest], ); const getClient = useCallback((): SandboxAgent => { if (!clientRef.current) { throw new Error("Not connected"); } return clientRef.current; }, []); const dismissErrorToast = useCallback((toastId: number) => { const timeoutId = toastTimeoutsRef.current.get(toastId); if (timeoutId != null) { window.clearTimeout(timeoutId); toastTimeoutsRef.current.delete(toastId); } toastExpiryRef.current.delete(toastId); toastRemainingMsRef.current.delete(toastId); setErrorToasts((prev) => prev.filter((toast) => toast.id !== toastId)); }, []); const scheduleErrorToastDismiss = useCallback( (toastId: number, delayMs: number) => { const existingTimeoutId = toastTimeoutsRef.current.get(toastId); if (existingTimeoutId != null) { window.clearTimeout(existingTimeoutId); toastTimeoutsRef.current.delete(toastId); } const clampedDelayMs = Math.max(0, delayMs); const timeoutId = window.setTimeout(() => { dismissErrorToast(toastId); }, clampedDelayMs); toastTimeoutsRef.current.set(toastId, timeoutId); toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs); toastRemainingMsRef.current.set(toastId, clampedDelayMs); }, [dismissErrorToast], ); const pauseErrorToastDismiss = useCallback((toastId: number) => { const expiryMs = toastExpiryRef.current.get(toastId); if (expiryMs == null) return; const remainingMs = Math.max(0, expiryMs - Date.now()); toastRemainingMsRef.current.set(toastId, remainingMs); const timeoutId = toastTimeoutsRef.current.get(toastId); if (timeoutId != null) { window.clearTimeout(timeoutId); toastTimeoutsRef.current.delete(toastId); } toastExpiryRef.current.delete(toastId); }, []); const resumeErrorToastDismiss = useCallback( (toastId: number) => { if (toastTimeoutsRef.current.has(toastId)) return; const remainingMs = toastRemainingMsRef.current.get(toastId); if (remainingMs == null) return; scheduleErrorToastDismiss(toastId, remainingMs); }, [scheduleErrorToastDismiss], ); const pushErrorToast = useCallback( (error: unknown, fallback: string) => { const messageText = getErrorMessage(error, fallback).trim() || fallback; const toastId = toastIdRef.current++; setErrorToasts((prev) => { if (prev.some((toast) => toast.message === messageText)) { return prev; } return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS); }); scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS); }, [scheduleErrorToastDismiss], ); // Subscribe to events for the current active session const subscribeToSession = useCallback( (session: Session) => { const generation = ++subscriptionGenerationRef.current; const isCurrentSubscription = (): boolean => subscriptionGenerationRef.current === generation && activeSessionRef.current?.id === session.id && selectedSessionIdRef.current === session.id; // Unsubscribe from previous if (eventUnsubRef.current) { eventUnsubRef.current(); eventUnsubRef.current = null; } activeSessionRef.current = session; const cachedEvents = sessionEventsCacheRef.current.get(session.id); if (cachedEvents && isCurrentSubscription()) { setEvents(cachedEvents); setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); } else if (isCurrentSubscription()) { setHistoryLoadingSessionId(session.id); } // Hydrate existing events from persistence const hydrateEvents = async () => { const allEvents: SessionEvent[] = []; let cursor: string | undefined; while (true) { const page = await getClient().getEvents({ sessionId: session.id, cursor, limit: 250, }); allEvents.push(...page.items); if (!page.nextCursor) break; cursor = page.nextCursor; } sessionEventsCacheRef.current.set(session.id, allEvents); if (!isCurrentSubscription()) return; setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents)); setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); }; hydrateEvents().catch((error) => { console.error("Failed to hydrate events:", error); if (isCurrentSubscription()) { setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); } }); // Subscribe to new events const unsub = session.onEvent((event) => { if (!isCurrentSubscription()) return; setEvents((prev) => { if (prev.some((existing) => existing.id === event.id)) { return prev; } const next = [...prev, event]; sessionEventsCacheRef.current.set(session.id, next); return next; }); }); eventUnsubRef.current = unsub; // Subscribe to permission requests if (permissionUnsubRef.current) { permissionUnsubRef.current(); permissionUnsubRef.current = null; } const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => { if (!isCurrentSubscription()) return; pendingPermissionsRef.current.set(request.id, request); if (request.toolCall?.toolCallId) { permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id); } setPendingPermissionIds((prev) => new Set([...prev, request.id])); }); permissionUnsubRef.current = permUnsub; }, [getClient], ); const handlePermissionReply = useCallback( async (permissionId: string, reply: PermissionReply) => { const session = activeSessionRef.current; if (!session) return; try { await session.respondPermission(permissionId, reply); const request = pendingPermissionsRef.current.get(permissionId); const selectedOption = request?.options.find((o) => reply === "always" ? o.kind === "allow_always" : reply === "once" ? o.kind === "allow_once" : o.kind === "reject_once" || o.kind === "reject_always", ); setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]])); setPendingPermissionIds((prev) => { const next = new Set(prev); next.delete(permissionId); return next; }); } catch (error) { pushErrorToast(error, "Failed to respond to permission request"); } }, [pushErrorToast], ); const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => { setConnecting(true); if (reportError) { setConnectError(null); } try { // Ensure reconnects do not keep stale session subscriptions/clients around. if (eventUnsubRef.current) { eventUnsubRef.current(); eventUnsubRef.current = null; } if (permissionUnsubRef.current) { permissionUnsubRef.current(); permissionUnsubRef.current = null; } subscriptionGenerationRef.current += 1; activeSessionRef.current = null; if (clientRef.current) { try { await clientRef.current.dispose(); } catch (disposeError) { console.warn("Failed to dispose previous client during reconnect:", disposeError); } finally { clientRef.current = null; } } const client = await createClient(overrideEndpoint); await client.getHealth(); if (overrideEndpoint) { setEndpoint(overrideEndpoint); } setConnected(true); await refreshAgents(); await fetchSessions(); if (sessionId) { try { const resumed = await client.resumeSession(sessionId); subscribeToSession(resumed); } catch (resumeError) { console.warn("Failed to resume current session after reconnect:", resumeError); setHistoryLoadingSessionId(null); } } if (reportError) { setConnectError(null); } } catch (error) { if (reportError) { const messageText = getErrorMessage(error, "Unable to connect"); setConnectError(messageText); } setConnected(false); clientRef.current = null; throw error; } finally { setConnecting(false); } }; const connect = () => connectToDaemon(true); const disconnect = () => { if (eventUnsubRef.current) { eventUnsubRef.current(); eventUnsubRef.current = null; } if (permissionUnsubRef.current) { permissionUnsubRef.current(); permissionUnsubRef.current = null; } subscriptionGenerationRef.current += 1; activeSessionRef.current = null; if (clientRef.current) { void clientRef.current.dispose().catch((error) => { console.warn("Failed to dispose client on disconnect:", error); }); } setConnected(false); clientRef.current = null; setSessionError(null); setEvents([]); setHistoryLoadingSessionId(null); setAgents([]); setSessions([]); sessionEventsCacheRef.current.clear(); setAgentsLoading(false); setSessionsLoading(false); setAgentsError(null); setSessionsError(null); }; const refreshAgents = async () => { setAgentsLoading(true); setAgentsError(null); try { const data = await getClient().listAgents(); setAgents(data.agents ?? []); } catch (error) { setAgentsError(getErrorMessage(error, "Unable to refresh agents")); } finally { setAgentsLoading(false); } }; const loadAgentConfig = useCallback( async (targetAgentId: string) => { console.log("[loadAgentConfig] Loading config for agent:", targetAgentId); try { const info = await getClient().getAgent(targetAgentId, { config: true }); console.log("[loadAgentConfig] Got agent info:", info); setAgents((prev) => prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a))); } catch (error) { console.error("[loadAgentConfig] Failed to load config:", error); // Config loading is best-effort; the menu still works without it. } }, [getClient], ); const fetchSessions = async () => { setSessionsLoading(true); setSessionsError(null); try { const archivedSessionIds = getArchivedSessionIds(); // TODO: This eagerly paginates all sessions so we can reverse-sort to // show newest first. Replace with a server-side descending sort or a // dedicated "recent sessions" query once the API supports it. const all: SessionListItem[] = []; let cursor: string | undefined; do { const page = await getClient().listSessions({ cursor, limit: 200 }); for (const s of page.items) { all.push({ sessionId: s.id, agent: s.agent, ended: s.destroyedAt != null, archived: archivedSessionIds.has(s.id), }); } cursor = page.nextCursor; } while (cursor); all.reverse(); setSessions(all); } catch { setSessionsError("Unable to load sessions."); } finally { setSessionsLoading(false); } }; const archiveSession = async (targetSessionId: string) => { archiveSessionId(targetSessionId); try { try { await getClient().destroySession(targetSessionId); } catch (error) { // If the server already considers the session gone, still archive in local UI. console.warn("Destroy session returned an error while archiving:", error); } setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: true, ended: true } : session))); setSessionModelById((prev) => { if (!(targetSessionId in prev)) return prev; const next = { ...prev }; delete next[targetSessionId]; return next; }); await fetchSessions(); } catch (error) { console.error("Failed to archive session:", error); } }; const unarchiveSession = async (targetSessionId: string) => { unarchiveSessionId(targetSessionId); setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: false } : session))); await fetchSessions(); }; const installAgent = async (targetId: string, reinstall: boolean) => { try { await getClient().installAgent(targetId, { reinstall }); await refreshAgents(); } catch (error) { setConnectError(getErrorMessage(error, "Install failed")); } }; const sendMessage = async () => { const prompt = message.trim(); if (!prompt || !sessionId || sendingSessionId) return; const targetSessionId = sessionId; setSessionError(null); setMessage(""); setSendingSessionId(targetSessionId); try { let session = activeSessionRef.current; if (!session || session.id !== targetSessionId) { session = await getClient().resumeSession(targetSessionId); subscribeToSession(session); } await session.prompt([{ type: "text", text: prompt }]); } catch (error) { setSessionError(getErrorMessage(error, "Unable to send message")); } finally { setSendingSessionId(null); } }; const selectSession = (session: SessionListItem) => { setSessionId(session.sessionId); selectedSessionIdRef.current = session.sessionId; updateSessionPath(session.sessionId); setAgentId(session.agent); setSessionModelById((prev) => { if (prev[session.sessionId]) return prev; const fallbackModel = defaultModelByAgent[session.agent]; if (!fallbackModel) return prev; return { ...prev, [session.sessionId]: fallbackModel }; }); const cachedEvents = sessionEventsCacheRef.current.get(session.sessionId); if (cachedEvents) { setEvents(cachedEvents); setHistoryLoadingSessionId(null); } else { setEvents([]); setHistoryLoadingSessionId(session.sessionId); } setSessionError(null); }; const createNewSession = async (nextAgentId: string, config: { agentMode: string; model: string }) => { console.log("[createNewSession] Creating session for agent:", nextAgentId, "config:", config); setSessionError(null); creatingSessionRef.current = true; createNoiseIgnoreUntilRef.current = Date.now() + 10_000; try { console.log("[createNewSession] Calling createSession..."); const createSessionPromise = getClient().createSession({ agent: nextAgentId, sessionInit: { cwd: "/", mcpServers: [], }, }); let slowWarningShown = false; const slowWarningTimerId = window.setTimeout(() => { slowWarningShown = true; setSessionError("Session creation is taking longer than expected. Waiting for agent startup..."); }, CREATE_SESSION_SLOW_WARNING_MS); let session: Awaited>; try { session = await createSessionPromise; } finally { window.clearTimeout(slowWarningTimerId); } console.log("[createNewSession] Session created:", session.id); if (slowWarningShown) { setSessionError(null); } setAgentId(nextAgentId); setEvents([]); setHistoryLoadingSessionId(null); setSessionId(session.id); selectedSessionIdRef.current = session.id; updateSessionPath(session.id); sessionEventsCacheRef.current.set(session.id, []); subscribeToSession(session); const skipPostCreateConfig = nextAgentId === "opencode"; // Apply mode if selected if (!skipPostCreateConfig && config.agentMode) { try { await session.rawSend("session/set_mode", { modeId: config.agentMode }); } catch { // Mode application is best-effort } } // Apply model if selected if (config.model) { setSessionModelById((prev) => ({ ...prev, [session.id]: config.model })); if (!skipPostCreateConfig) { try { const agentInfo = agents.find((agent) => agent.id === nextAgentId); const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find( (opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string", ); if (modelOption && config.model !== modelOption.currentValue) { await session.rawSend("session/set_config_option", { optionId: modelOption.id, value: config.model, }); } } catch { // Model application is best-effort } } } // Refresh session list in background; UI should not stay blocked on list pagination. void fetchSessions(); } catch (error) { console.error("[createNewSession] Failed to create session:", error); const messageText = getErrorMessage(error, "Unable to create session"); console.error("[createNewSession] Error message:", messageText); setSessionError(messageText); pushErrorToast(error, messageText); if (!reconnectingAfterCreateFailureRef.current) { reconnectingAfterCreateFailureRef.current = true; // Run recovery in background so failed creates do not block UI. void connectToDaemon(false) .catch((reconnectError) => { console.error("[createNewSession] Soft reconnect failed:", reconnectError); }) .finally(() => { reconnectingAfterCreateFailureRef.current = false; }); } throw error; } finally { creatingSessionRef.current = false; // Keep a short post-create window for delayed transport rejections. createNoiseIgnoreUntilRef.current = Date.now() + 2_000; } }; const endSession = async () => { if (!sessionId) return; try { await getClient().destroySession(sessionId); if (eventUnsubRef.current) { eventUnsubRef.current(); eventUnsubRef.current = null; } if (permissionUnsubRef.current) { permissionUnsubRef.current(); permissionUnsubRef.current = null; } activeSessionRef.current = null; await fetchSessions(); } catch (error) { setSessionError(getErrorMessage(error, "Unable to end session")); } }; const handleCopy = (entry: RequestLog) => { const text = entry.curl; const onSuccess = () => { setCopiedLogId(entry.id); window.setTimeout(() => setCopiedLogId(null), 1500); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(text) .then(onSuccess) .catch(() => { fallbackCopy(text, onSuccess); }); } else { fallbackCopy(text, onSuccess); } }; const fallbackCopy = (text: string, onSuccess?: () => void) => { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); onSuccess?.(); } catch (err) { console.error("Failed to copy:", err); } document.body.removeChild(textarea); }; // Build transcript entries from raw SessionEvents const transcriptEntries = useMemo(() => { const entries: TranscriptEntry[] = []; // Accumulators for streaming chunks let assistantAccumId: string | null = null; let assistantAccumText = ""; let thoughtAccumId: string | null = null; let thoughtAccumText = ""; const flushAssistant = (time: string) => { if (assistantAccumId) { const existing = entries.find((e) => e.id === assistantAccumId); if (existing) { existing.text = assistantAccumText; existing.time = time; } } assistantAccumId = null; assistantAccumText = ""; }; const flushThought = (time: string) => { if (thoughtAccumId) { const existing = entries.find((e) => e.id === thoughtAccumId); if (existing && existing.reasoning) { existing.reasoning.text = thoughtAccumText; existing.time = time; } } thoughtAccumId = null; thoughtAccumText = ""; }; // Track tool calls by ID for updates const toolEntryMap = new Map(); for (const event of events) { const payload = event.payload as Record; const method = typeof payload.method === "string" ? payload.method : null; const time = new Date(event.createdAt).toISOString(); if (event.sender === "client" && method === "session/prompt") { // User message flushAssistant(time); flushThought(time); const params = payload.params as Record | undefined; const promptArray = params?.prompt as Array<{ type: string; text?: string }> | undefined; const replayPrefix = "Previous session history is replayed below"; const text = (promptArray ?? []) .filter((part) => part?.type === "text" && typeof part.text === "string") .map((part) => part.text!.trim()) // SDK replay prompt is prepended to the real user prompt; drop only replay blocks. .filter((partText) => partText.length > 0 && !partText.startsWith(replayPrefix)) .join("\n\n") .trim(); if (!text) { continue; } entries.push({ id: event.id, eventId: event.id, kind: "message", time, role: "user", text, }); continue; } if (event.sender === "agent" && method === "session/update") { const params = payload.params as Record | undefined; const update = params?.update as Record | undefined; if (!update || typeof update.sessionUpdate !== "string") continue; switch (update.sessionUpdate) { case "agent_message_chunk": { const content = update.content as { type?: string; text?: string } | undefined; if (content?.type === "text" && content.text) { if (!assistantAccumId) { assistantAccumId = `assistant-${event.id}`; assistantAccumText = ""; entries.push({ id: assistantAccumId, eventId: event.id, kind: "message", time, role: "assistant", text: "", }); } assistantAccumText += content.text; const entry = entries.find((e) => e.id === assistantAccumId); if (entry) { entry.text = assistantAccumText; entry.time = time; } } break; } case "agent_thought_chunk": { const content = update.content as { type?: string; text?: string } | undefined; if (content?.type === "text" && content.text) { if (!thoughtAccumId) { thoughtAccumId = `thought-${event.id}`; thoughtAccumText = ""; entries.push({ id: thoughtAccumId, eventId: event.id, kind: "reasoning", time, reasoning: { text: "", visibility: "public" }, }); } thoughtAccumText += content.text; const entry = entries.find((e) => e.id === thoughtAccumId); if (entry && entry.reasoning) { entry.reasoning.text = thoughtAccumText; entry.time = time; } } break; } case "user_message_chunk": { const content = update.content as { type?: string; text?: string } | undefined; const text = content?.type === "text" ? (content.text ?? "") : JSON.stringify(content); entries.push({ id: event.id, eventId: event.id, kind: "message", time, role: "user", text, }); break; } case "tool_call": { flushAssistant(time); flushThought(time); const toolCallId = (update.toolCallId as string) ?? event.id; const existing = toolEntryMap.get(toolCallId); if (existing) { // Update existing entry instead of creating a duplicate if (update.status) existing.toolStatus = update.status as string; if (update.rawInput != null) existing.toolInput = JSON.stringify(update.rawInput, null, 2); if (update.rawOutput != null) existing.toolOutput = JSON.stringify(update.rawOutput, null, 2); if (update.title) existing.toolName = update.title as string; existing.time = time; } else { const entry: TranscriptEntry = { id: `tool-${toolCallId}`, eventId: event.id, kind: "tool", time, toolName: (update.title as string) ?? "tool", toolInput: update.rawInput != null ? JSON.stringify(update.rawInput, null, 2) : undefined, toolOutput: update.rawOutput != null ? JSON.stringify(update.rawOutput, null, 2) : undefined, toolStatus: (update.status as string) ?? "in_progress", }; toolEntryMap.set(toolCallId, entry); entries.push(entry); } break; } case "tool_call_update": { const toolCallId = update.toolCallId as string; const existing = toolEntryMap.get(toolCallId); if (existing) { if (update.status) existing.toolStatus = update.status as string; if (update.rawOutput != null) existing.toolOutput = JSON.stringify(update.rawOutput, null, 2); if (update.title) existing.toolName = (existing.toolName ?? "") + (update.title as string); existing.time = time; } break; } case "plan": { const planEntries = (update.entries as Array<{ content: string; status: string }>) ?? []; const detail = planEntries.map((e) => `[${e.status}] ${e.content}`).join("\n"); entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Plan", detail, severity: "info" }, }); break; } case "session_info_update": { const title = update.title as string | undefined; entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Session info update", detail: title ? `Title: ${title}` : undefined, severity: "info" }, }); break; } case "usage_update": { // Token usage is displayed in the config bar, not in the transcript break; } case "current_mode_update": { entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Mode changed", detail: update.currentModeId as string, severity: "info" }, }); break; } case "config_option_update": { entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Config option update", severity: "info" }, }); break; } case "available_commands_update": { entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Available commands update", severity: "info" }, }); break; } default: { entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: `session/update: ${update.sessionUpdate}`, severity: "info" }, }); break; } } continue; } if (event.sender === "agent" && method === "session/request_permission") { const params = payload.params as | { options?: Array<{ optionId: string; name: string; kind: string }>; toolCall?: { title?: string; toolCallId?: string; description?: string }; } | undefined; const toolCallId = params?.toolCall?.toolCallId; const sdkPermissionId = toolCallId ? permissionToolCallToIdRef.current.get(toolCallId) : undefined; const permissionId = sdkPermissionId ?? (typeof payload.id === "number" || typeof payload.id === "string" ? String(payload.id) : event.id); const options = (params?.options ?? []).map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind, })); const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request"; const resolved = resolvedPermissions.get(permissionId); entries.push({ id: event.id, eventId: event.id, kind: "permission", time, permission: { permissionId, title, description: params?.toolCall?.description, options, resolved: resolved != null || sdkPermissionId == null, selectedOptionId: resolved, }, }); continue; } if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") { const params = payload.params as { error?: string; location?: string } | undefined; entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: "Agent parse failure", detail: `${params?.location ?? "unknown"}: ${params?.error ?? "unknown error"}`, severity: "error", }, }); continue; } // For any other ACP envelope, show as generic meta if (method) { entries.push({ id: event.id, eventId: event.id, kind: "meta", time, meta: { title: method, detail: event.sender, severity: "info" }, }); } } return entries; }, [events, resolvedPermissions]); useEffect(() => { return () => { if (eventUnsubRef.current) { eventUnsubRef.current(); eventUnsubRef.current = null; } if (permissionUnsubRef.current) { permissionUnsubRef.current(); permissionUnsubRef.current = null; } }; }, []); useEffect(() => { const shouldIgnoreCreateNoise = (value: unknown): boolean => { if (Date.now() > createNoiseIgnoreUntilRef.current) return false; const message = getErrorMessage(value, "").trim().toLowerCase(); return message.length === 0 || message === "request failed" || message.includes("request failed") || message.includes("unhandled promise rejection"); }; const handleWindowError = (event: ErrorEvent) => { const errorLike = event.error ?? event.message; if (shouldIgnoreCreateNoise(errorLike)) return; if (shouldIgnoreGlobalError(errorLike)) return; pushErrorToast(errorLike, "Unexpected error"); }; const handleUnhandledRejection = (event: PromiseRejectionEvent) => { if (shouldIgnoreCreateNoise(event.reason)) { event.preventDefault(); return; } if (shouldIgnoreGlobalError(event.reason)) { event.preventDefault(); return; } pushErrorToast(event.reason, "Unhandled promise rejection"); }; const handleHttpError = (event: Event) => { const detail = (event as CustomEvent).detail; if (typeof detail === "string" && detail.trim()) { pushErrorToast(new Error(detail), detail); } }; window.addEventListener("error", handleWindowError); window.addEventListener("unhandledrejection", handleUnhandledRejection); window.addEventListener(HTTP_ERROR_EVENT, handleHttpError); return () => { window.removeEventListener("error", handleWindowError); window.removeEventListener("unhandledrejection", handleUnhandledRejection); window.removeEventListener(HTTP_ERROR_EVENT, handleHttpError); }; }, [pushErrorToast]); useEffect(() => { return () => { for (const timeoutId of toastTimeoutsRef.current.values()) { window.clearTimeout(timeoutId); } toastTimeoutsRef.current.clear(); toastExpiryRef.current.clear(); toastRemainingMsRef.current.clear(); }; }, []); useEffect(() => { let active = true; const attempt = async () => { const { hasUrlParam } = initialConnectionRef.current; if (hasUrlParam) { try { await connectToDaemon(false); } catch { // Keep the URL param endpoint in the form even if connection failed } return; } const originEndpoint = getCurrentOriginEndpoint(); if (originEndpoint) { try { await connectToDaemon(false, originEndpoint); return; } catch { // Origin failed, continue to fallback } } if (!active) return; try { await connectToDaemon(false, DEFAULT_ENDPOINT); } catch { setEndpoint(DEFAULT_ENDPOINT); } }; attempt().catch(() => { if (!active) return; setConnecting(false); }); return () => { active = false; }; }, []); useEffect(() => { if (!connected) return; refreshAgents(); }, [connected]); // Auto-load session when sessionId changes useEffect(() => { if (!connected || !sessionId) return; if (creatingSessionRef.current) return; if (resumeInFlightSessionIdRef.current === sessionId) return; const sessionInfo = sessions.find((s) => s.sessionId === sessionId); if (!sessionInfo) return; if (activeSessionRef.current?.id === sessionId) return; // Set the correct agent from the session setAgentId(sessionInfo.agent); const cachedEvents = sessionEventsCacheRef.current.get(sessionId); if (cachedEvents) { setEvents(cachedEvents); setHistoryLoadingSessionId(null); } else { setEvents([]); setHistoryLoadingSessionId(sessionId); } setSessionError(null); const requestedSessionId = sessionId; resumeInFlightSessionIdRef.current = requestedSessionId; getClient() .resumeSession(requestedSessionId) .then((session) => { if (selectedSessionIdRef.current !== requestedSessionId) return; subscribeToSession(session); }) .catch((error) => { if (selectedSessionIdRef.current !== requestedSessionId) return; setSessionError(getErrorMessage(error, "Unable to resume session")); setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current)); }) .finally(() => { if (resumeInFlightSessionIdRef.current === requestedSessionId) { resumeInFlightSessionIdRef.current = null; } }); }, [connected, sessionId, sessions, getClient, subscribeToSession]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [transcriptEntries]); const currentAgent = agents.find((agent) => agent.id === agentId); const agentLabel = agentDisplayNames[agentId] ?? agentId; const selectedSession = sessions.find((s) => s.sessionId === sessionId); const sessionArchived = selectedSession?.archived ?? false; // Archived sessions are treated as ended in UI so they can never be "ended again". const sessionEnded = (selectedSession?.ended ?? false) || sessionArchived; // Determine if agent is thinking (has in-progress tools or waiting for response) const isThinking = useMemo(() => { if (!sessionId || sessionEnded) return false; // If actively sending a prompt, show thinking if (sendingSessionId === sessionId) return true; // Check for in-progress tool calls const hasInProgressTool = transcriptEntries.some((e) => e.kind === "tool" && e.toolStatus === "in_progress"); if (hasInProgressTool) return true; // Check if last message was from user with no subsequent agent activity const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user"); if (lastUserMessageIndex === -1) return false; // If user message is the very last entry, we're waiting for response if (lastUserMessageIndex === 0) return true; // Check if there's any agent response after the user message const entriesAfterUser = transcriptEntries.slice(-lastUserMessageIndex); const hasAgentResponse = entriesAfterUser.some((e) => e.kind === "message" && e.role === "assistant"); // If no assistant message after user, but there are completed tools, not thinking const hasCompletedTools = entriesAfterUser.some((e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed")); if (!hasAgentResponse && !hasCompletedTools) return true; return false; }, [sessionId, sessionEnded, transcriptEntries, sendingSessionId]); // Extract latest token usage from events const tokenUsage = useMemo(() => { let latest: { used: number; size: number; cost?: number } | null = null; for (const event of events) { const payload = event.payload as Record; const method = typeof payload.method === "string" ? payload.method : null; if (event.sender === "agent" && method === "session/update") { const params = payload.params as Record | undefined; const update = params?.update as Record | undefined; if (update?.sessionUpdate === "usage_update") { latest = { used: (update.used as number) ?? 0, size: (update.size as number) ?? 0, cost: (update.cost as { total?: number })?.total, }; } } } return latest; }, [events]); // Extract modes and models from configOptions const modesByAgent = useMemo(() => { const result: Record = {}; for (const agent of agents) { const options = (agent.configOptions ?? []) as ConfigOption[]; for (const opt of options) { if (opt.category === "mode" && opt.type === "select" && opt.options) { result[agent.id] = flattenSelectOptions(opt.options).map((o) => ({ id: o.value, name: o.name, description: o.description ?? "", })); } } } return result; }, [agents]); const modelsByAgent = useMemo(() => { const result: Record = {}; for (const agent of agents) { const options = (agent.configOptions ?? []) as ConfigOption[]; for (const opt of options) { if (opt.category === "model" && opt.type === "select" && opt.options) { result[agent.id] = flattenSelectOptions(opt.options).map((o) => ({ id: o.value, name: o.name, })); } } } return result; }, [agents]); const defaultModelByAgent = useMemo(() => { const result: Record = {}; for (const agent of agents) { const options = (agent.configOptions ?? []) as ConfigOption[]; for (const opt of options) { if (opt.category === "model" && opt.type === "select" && opt.currentValue) { result[agent.id] = opt.currentValue; } } } return result; }, [agents]); const currentSessionModelId = useMemo(() => { let latestModelId: string | null = null; for (const event of events) { const payload = event.payload as Record; const method = typeof payload.method === "string" ? payload.method : null; const params = payload.params as Record | undefined; if (event.sender === "agent" && method === "session/update") { const update = params?.update as Record | undefined; if (update?.sessionUpdate !== "config_option_update") continue; const category = (update.category as string | undefined) ?? ((update.option as Record | undefined)?.category as string | undefined); if (category && category !== "model") continue; const optionId = (update.optionId as string | undefined) ?? (update.configOptionId as string | undefined) ?? ((update.option as Record | undefined)?.id as string | undefined); const seemsModelOption = !optionId || optionId.toLowerCase().includes("model"); if (!seemsModelOption) continue; const candidate = (update.value as string | undefined) ?? (update.currentValue as string | undefined) ?? (update.selectedValue as string | undefined) ?? (update.modelId as string | undefined); if (candidate) { latestModelId = candidate; } continue; } // Capture explicit client-side model changes; these are persisted and survive refresh. if (event.sender === "client" && method === "unstable/set_session_model") { const candidate = params?.modelId as string | undefined; if (candidate) { latestModelId = candidate; } continue; } if (event.sender === "client" && method === "session/set_config_option") { const category = params?.category as string | undefined; const optionId = params?.optionId as string | undefined; const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model")); if (!seemsModelOption) continue; const candidate = (params?.value as string | undefined) ?? (params?.currentValue as string | undefined) ?? (params?.modelId as string | undefined); if (candidate) { latestModelId = candidate; } } } return latestModelId; }, [events]); const modelPillLabel = useMemo(() => { const sessionModelId = currentSessionModelId ?? (sessionId ? sessionModelById[sessionId] : undefined) ?? (sessionId ? defaultModelByAgent[agentId] : undefined); if (!sessionModelId) return null; return sessionModelId; }, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]); useEffect(() => { if (!sessionId || !currentSessionModelId) return; setSessionModelById((prev) => (prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId })); }, [currentSessionModelId, sessionId]); useEffect(() => { try { window.localStorage.setItem(SESSION_MODELS_KEY, JSON.stringify(sessionModelById)); } catch { // Ignore storage write failures. } }, [sessionModelById]); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }; const toastStack = (
{errorToasts.map((toast) => (
pauseErrorToastDismiss(toast.id)} onMouseLeave={() => resumeErrorToastDismiss(toast.id)} onFocus={() => pauseErrorToastDismiss(toast.id)} onBlur={() => resumeErrorToastDismiss(toast.id)} >
Request failed
{toast.message}
))}
); if (!connected) { return ( <> {toastStack} ); } return (
Sandbox Agent {endpoint}
({ id, installed: false, credentialsAvailable: true, capabilities: {} as AgentInfo["capabilities"], })) } agentsLoading={agentsLoading} agentsError={agentsError} sessionsLoading={sessionsLoading} sessionsError={sessionsError} modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} /> ({ id, installed: false, credentialsAvailable: true, capabilities: {} as AgentInfo["capabilities"], })) } agentsLoading={agentsLoading} agentsError={agentsError} messagesEndRef={messagesEndRef} agentLabel={agentLabel} modelLabel={modelPillLabel} currentAgentVersion={currentAgent?.version ?? null} sessionEnded={sessionEnded} sessionArchived={sessionArchived} onEndSession={endSession} onArchiveSession={() => { if (sessionId) { void archiveSession(sessionId); } }} onUnarchiveSession={() => { if (sessionId) { void unarchiveSession(sessionId); } }} modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} onEventClick={(eventId) => { setDebugTab("events"); setHighlightedEventId(eventId); }} isThinking={isThinking} agentId={agentId} tokenUsage={tokenUsage} onPermissionReply={handlePermissionReply} /> setEvents([])} highlightedEventId={highlightedEventId} onClearHighlight={() => setHighlightedEventId(null)} requestLog={requestLog} copiedLogId={copiedLogId} onClearRequestLog={() => setRequestLog([])} onCopyRequestLog={handleCopy} agents={agents} defaultAgents={defaultAgents} modesByAgent={modesByAgent} onRefreshAgents={refreshAgents} onInstallAgent={installAgent} agentsLoading={agentsLoading} agentsError={agentsError} getClient={getClient} collapsed={debugPanelCollapsed} onToggleCollapse={() => setDebugPanelCollapsed(!debugPanelCollapsed)} />
{toastStack}
); }