sandbox-agent/frontend/packages/inspector/src/App.tsx
Nathan Flurry d2346bafb3
Configure lefthook formatter checks (#231)
* Add lefthook formatter checks

* Fix SDK mode hydration

* Stabilize SDK mode integration test
2026-03-10 23:03:11 -07:00

1802 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> }).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<string> => {
if (typeof window === "undefined") return new Set<string>();
try {
const raw = window.localStorage.getItem(ARCHIVED_SESSIONS_KEY);
if (!raw) return new Set<string>();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set<string>();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
} catch {
return new Set<string>();
}
};
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<string, string> => {
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<string, string>, 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<string, string> = {};
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<string, string> = {
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<string | null>(null);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [agentsLoading, setAgentsLoading] = useState(false);
const [agentsError, setAgentsError] = useState<string | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [agentId, setAgentId] = useState("claude");
const [sessionId, setSessionId] = useState(getSessionIdFromPath());
const [sessionError, setSessionError] = useState<string | null>(null);
const [sessionModelById, setSessionModelById] = useState<Record<string, string>>(() => getPersistedSessionModels());
const [message, setMessage] = useState("");
const [events, setEvents] = useState<SessionEvent[]>([]);
const [sendingSessionId, setSendingSessionId] = useState<string | null>(null);
const [historyLoadingSessionId, setHistoryLoadingSessionId] = useState<string | null>(null);
const [requestLog, setRequestLog] = useState<RequestLog[]>([]);
const logIdRef = useRef(1);
const [copiedLogId, setCopiedLogId] = useState<number | null>(null);
const [errorToasts, setErrorToasts] = useState<ErrorToast[]>([]);
const toastIdRef = useRef(1);
const toastTimeoutsRef = useRef<Map<number, number>>(new Map());
const toastExpiryRef = useRef<Map<number, number>>(new Map());
const toastRemainingMsRef = useRef<Map<number, number>>(new Map());
const [debugTab, setDebugTab] = useState<DebugTab>("events");
const [highlightedEventId, setHighlightedEventId] = useState<string | null>(null);
const [debugPanelCollapsed, setDebugPanelCollapsed] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const clientRef = useRef<SandboxAgent | null>(null);
const activeSessionRef = useRef<Session | null>(null);
const eventUnsubRef = useRef<(() => void) | null>(null);
const permissionUnsubRef = useRef<(() => void) | null>(null);
const pendingPermissionsRef = useRef<Map<string, SessionPermissionRequest>>(new Map());
const permissionToolCallToIdRef = useRef<Map<string, string>>(new Map());
const [pendingPermissionIds, setPendingPermissionIds] = useState<Set<string>>(new Set());
const [resolvedPermissions, setResolvedPermissions] = useState<Map<string, string>>(new Map());
const sessionEventsCacheRef = useRef<Map<string, SessionEvent[]>>(new Map());
const selectedSessionIdRef = useRef(sessionId);
const resumeInFlightSessionIdRef = useRef<string | null>(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<string, string> = {};
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<string>(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<ReturnType<SandboxAgent["createSession"]>>;
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<string, TranscriptEntry>();
for (const event of events) {
const payload = event.payload as Record<string, unknown>;
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<string, unknown> | 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<string, unknown> | undefined;
const update = params?.update as Record<string, unknown> | 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<string>).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<string, unknown>;
const method = typeof payload.method === "string" ? payload.method : null;
if (event.sender === "agent" && method === "session/update") {
const params = payload.params as Record<string, unknown> | undefined;
const update = params?.update as Record<string, unknown> | 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<string, AgentModeInfo[]> = {};
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<string, AgentModelInfo[]> = {};
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<string, string> = {};
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<string, unknown>;
const method = typeof payload.method === "string" ? payload.method : null;
const params = payload.params as Record<string, unknown> | undefined;
if (event.sender === "agent" && method === "session/update") {
const update = params?.update as Record<string, unknown> | undefined;
if (update?.sessionUpdate !== "config_option_update") continue;
const category = (update.category as string | undefined) ?? ((update.option as Record<string, unknown> | 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<string, unknown> | 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<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
};
const toastStack = (
<div className="toast-stack" aria-live="assertive" aria-atomic="false">
{errorToasts.map((toast) => (
<div
key={toast.id}
className="toast error"
role="status"
onMouseEnter={() => pauseErrorToastDismiss(toast.id)}
onMouseLeave={() => resumeErrorToastDismiss(toast.id)}
onFocus={() => pauseErrorToastDismiss(toast.id)}
onBlur={() => resumeErrorToastDismiss(toast.id)}
>
<button type="button" className="toast-close" aria-label="Dismiss error" onClick={() => dismissErrorToast(toast.id)}>
×
</button>
<div className="toast-content">
<div className="toast-title">Request failed</div>
<div className="toast-message">{toast.message}</div>
</div>
</div>
))}
</div>
);
if (!connected) {
return (
<>
<ConnectScreen
endpoint={endpoint}
token={token}
connectError={connectError}
connecting={connecting}
onEndpointChange={setEndpoint}
onTokenChange={setToken}
onConnect={connect}
reportUrl={issueTrackerUrl}
docsUrl={docsUrl}
discordUrl={discordUrl}
/>
{toastStack}
</>
);
}
return (
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: "20px", width: "auto" }} />
<span className="header-endpoint">{endpoint}</span>
</div>
<div className="header-right">
<a className="header-link" href={docsUrl} target="_blank" rel="noreferrer">
<BookOpen size={12} />
Docs
</a>
<a className="header-link" href={discordUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</a>
<a className="header-link" href={issueTrackerUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
Issues
</a>
<button className="button secondary small" onClick={disconnect}>
Disconnect
</button>
</div>
</header>
<main className={`main-layout ${debugPanelCollapsed ? "debug-collapsed" : ""}`}>
<SessionSidebar
sessions={sessions}
selectedSessionId={sessionId}
onSelectSession={selectSession}
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
sessionsError={sessionsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
/>
<ChatPanel
sessionId={sessionId}
transcriptEntries={transcriptEntries}
isLoadingHistory={historyLoadingSessionId === sessionId}
sessionError={sessionError}
message={message}
onMessageChange={setMessage}
onSendMessage={sendMessage}
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
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}
/>
<DebugPanel
debugTab={debugTab}
onDebugTabChange={setDebugTab}
events={events}
onResetEvents={() => 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)}
/>
</main>
{toastStack}
</div>
);
}