Configure lefthook formatter checks (#231)

* Add lefthook formatter checks

* Fix SDK mode hydration

* Stabilize SDK mode integration test
This commit is contained in:
Nathan Flurry 2026-03-10 23:03:11 -07:00 committed by GitHub
parent 0471214d65
commit d2346bafb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
282 changed files with 5840 additions and 8399 deletions

View file

@ -32,9 +32,7 @@ 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[] => {
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);
@ -186,9 +184,7 @@ const getPersistedSessionModels = (): Record<string, string> => {
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
)
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0),
);
} catch {
return {};
@ -230,14 +226,12 @@ const getInitialConnection = () => {
}
}
const hasUrlParam = urlParam != null && urlParam.length > 0;
const defaultEndpoint = import.meta.env.DEV
? DEFAULT_ENDPOINT
: (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
const defaultEndpoint = import.meta.env.DEV ? DEFAULT_ENDPOINT : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
return {
endpoint: hasUrlParam ? urlParam : defaultEndpoint,
token: tokenParam,
headers,
hasUrlParam
hasUrlParam,
};
};
@ -247,7 +241,7 @@ const agentDisplayNames: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
export default function App() {
@ -324,96 +318,94 @@ export default function App() {
});
}, []);
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 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 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 entry: RequestLog = {
id: logId,
method,
url,
headers,
body: bodyText,
time: new Date().toLocaleTimeString(),
curl,
};
let logged = false;
const fetchInit = {
...init,
targetAddressSpace: "loopback"
};
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})`
);
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 }));
}
logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" });
logged = true;
return response;
} catch (error) {
const messageText = error instanceof Error ? error.message : "Request failed";
if (!logged) {
logRequest({ ...entry, status: 0, error: messageText });
}
throw error;
}
const 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,
});
}
};
let persist: SessionPersistDriver;
try {
persist = new IndexedDbSessionPersistDriver({
databaseName: "sandbox-agent-inspector",
const client = await SandboxAgent.connect({
baseUrl: targetEndpoint,
token: token || undefined,
fetch: fetchWithLog,
headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined,
persist,
});
} 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]);
clientRef.current = client;
return client;
},
[endpoint, token, extraHeaders, logRequest],
);
const getClient = useCallback((): SandboxAgent => {
if (!clientRef.current) {
@ -433,22 +425,25 @@ export default function App() {
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 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);
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]);
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);
@ -464,125 +459,133 @@ export default function App() {
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 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)) {
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;
}
const next = [...prev, event];
sessionEventsCacheRef.current.set(session.id, next);
return next;
return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS);
});
});
eventUnsubRef.current = unsub;
scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS);
},
[scheduleErrorToastDismiss],
);
// 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);
// 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;
}
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;
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));
}
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
}, [pushErrorToast]);
// 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);
@ -689,19 +692,20 @@ export default function App() {
}
};
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 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);
@ -743,13 +747,7 @@ export default function App() {
// 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
)
);
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 };
@ -764,11 +762,7 @@ export default function App() {
const unarchiveSession = async (targetSessionId: string) => {
unarchiveSessionId(targetSessionId);
setSessions((prev) =>
prev.map((session) =>
session.sessionId === targetSessionId ? { ...session, archived: false } : session
)
);
setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: false } : session)));
await fetchSessions();
};
@ -883,7 +877,7 @@ export default function App() {
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"
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string",
);
if (modelOption && config.model !== modelOption.currentValue) {
await session.rawSend("session/set_config_option", {
@ -951,9 +945,12 @@ export default function App() {
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
fallbackCopy(text, onSuccess);
});
navigator.clipboard
.writeText(text)
.then(onSuccess)
.catch(() => {
fallbackCopy(text, onSuccess);
});
} else {
fallbackCopy(text, onSuccess);
}
@ -1222,18 +1219,15 @@ export default function App() {
}
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 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 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,
@ -1306,12 +1300,7 @@ export default function App() {
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")
);
return message.length === 0 || message === "request failed" || message.includes("request failed") || message.includes("unhandled promise rejection");
};
const handleWindowError = (event: ErrorEvent) => {
@ -1427,18 +1416,22 @@ export default function App() {
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;
}
});
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(() => {
@ -1458,9 +1451,7 @@ export default function App() {
// 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"
);
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");
@ -1468,14 +1459,10 @@ export default function App() {
// 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"
);
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")
);
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]);
@ -1560,20 +1547,21 @@ export default function App() {
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);
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 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);
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;
}
@ -1594,9 +1582,7 @@ export default function App() {
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);
const candidate = (params?.value as string | undefined) ?? (params?.currentValue as string | undefined) ?? (params?.modelId as string | undefined);
if (candidate) {
latestModelId = candidate;
}
@ -1608,18 +1594,14 @@ export default function App() {
const modelPillLabel = useMemo(() => {
const sessionModelId =
currentSessionModelId
?? (sessionId ? sessionModelById[sessionId] : undefined)
?? (sessionId ? defaultModelByAgent[agentId] : undefined);
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 }
);
setSessionModelById((prev) => (prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }));
}, [currentSessionModelId, sessionId]);
useEffect(() => {
@ -1649,12 +1631,7 @@ export default function App() {
onFocus={() => pauseErrorToastDismiss(toast.id)}
onBlur={() => resumeErrorToastDismiss(toast.id)}
>
<button
type="button"
className="toast-close"
aria-label="Dismiss error"
onClick={() => dismissErrorToast(toast.id)}
>
<button type="button" className="toast-close" aria-label="Dismiss error" onClick={() => dismissErrorToast(toast.id)}>
×
</button>
<div className="toast-content">
@ -1690,7 +1667,7 @@ export default function App() {
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
<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">
@ -1699,11 +1676,15 @@ export default function App() {
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>
<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>
<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}>
@ -1720,12 +1701,16 @@ export default function App() {
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
@ -1746,12 +1731,16 @@ export default function App() {
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
messagesEndRef={messagesEndRef}

View file

@ -30,7 +30,7 @@ const ConnectScreen = ({
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: "20px", width: "auto" }} />
</div>
{(docsUrl || discordUrl || reportUrl) && (
<div className="header-right">
@ -42,13 +42,17 @@ const ConnectScreen = ({
)}
{discordUrl && (
<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>
<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>
)}
{reportUrl && (
<a className="header-link" href={reportUrl} 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>
<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>
)}
@ -59,7 +63,7 @@ const ConnectScreen = ({
<main className="landing">
<div className="landing-container">
<div className="landing-hero">
<img src={logoUrl} alt="Sandbox Agent" style={{ height: '32px', width: 'auto', marginBottom: '20px' }} />
<img src={logoUrl} alt="Sandbox Agent" style={{ height: "32px", width: "auto", marginBottom: "20px" }} />
</div>
<div className="connect-card">
@ -67,17 +71,15 @@ const ConnectScreen = ({
{connectError && <div className="banner error">{connectError}</div>}
{isHttpsToHttpConnection(window.location.href, endpoint) &&
isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires{" "}
<strong>local network access</strong> permission. Your browser may prompt you to
allow this connection.
</span>
</div>
)}
{isHttpsToHttpConnection(window.location.href, endpoint) && isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires <strong>local network access</strong> permission. Your browser may prompt you to allow
this connection.
</span>
</div>
)}
<label className="field">
<span className="label">Endpoint</span>
@ -92,13 +94,7 @@ const ConnectScreen = ({
<label className="field">
<span className="label">Token (optional)</span>
<input
className="input"
type="password"
placeholder="Bearer token"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
/>
<input className="input" type="password" placeholder="Bearer token" value={token} onChange={(event) => onTokenChange(event.target.value)} />
</label>
<button className="button primary" onClick={onConnect} disabled={connecting}>
@ -116,7 +112,11 @@ const ConnectScreen = ({
</button>
<p className="hint">
Having trouble connecting? See the <a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">CORS documentation</a>.
Having trouble connecting? See the{" "}
<a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">
CORS documentation
</a>
.
</p>
</div>
</div>

View file

@ -18,7 +18,7 @@ const agentLabels: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
const agentLogos: Record<string, string> = {
@ -39,7 +39,7 @@ const SessionCreateMenu = ({
onCreateSession,
onSelectAgent,
open,
onClose
onClose,
}: {
agents: AgentInfo[];
agentsLoading: boolean;
@ -157,54 +157,45 @@ const SessionCreateMenu = ({
<div className="session-create-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError && (() => {
const codingAgents = agents.filter((a) => a.id !== "mock");
const mockAgent = agents.find((a) => a.id === "mock");
return (
<>
{codingAgents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => handleAgentClick(agent.id)}
>
<div className="agent-option-left">
{agentLogos[agent.id] && (
<img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />
)}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
))}
{mockAgent && (
<>
<div className="agent-divider" />
<button
className="sidebar-add-option"
onClick={() => handleAgentClick(mockAgent.id)}
>
{!agentsLoading && !agentsError && agents.length === 0 && <div className="sidebar-add-status">No agents available.</div>}
{!agentsLoading &&
!agentsError &&
(() => {
const codingAgents = agents.filter((a) => a.id !== "mock");
const mockAgent = agents.find((a) => a.id === "mock");
return (
<>
{codingAgents.map((agent) => (
<button key={agent.id} className="sidebar-add-option" onClick={() => handleAgentClick(agent.id)}>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
{agentLogos[agent.id] && <img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
</>
)}
</>
);
})()}
))}
{mockAgent && (
<>
<div className="agent-divider" />
<button className="sidebar-add-option" onClick={() => handleAgentClick(mockAgent.id)}>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
</div>
<div className="agent-option-badges">
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
</>
)}
</>
);
})()}
</div>
);
}
@ -237,12 +228,7 @@ const SessionCreateMenu = ({
autoFocus
/>
) : (
<select
className="setup-select"
value={selectedModel}
onChange={(e) => handleModelSelectChange(e.target.value)}
title="Model"
>
<select className="setup-select" value={selectedModel} onChange={(e) => handleModelSelectChange(e.target.value)} title="Model">
{activeModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}
@ -258,9 +244,7 @@ const SessionCreateMenu = ({
setIsCustomModel(false);
setCustomModel("");
const defaultModel = defaultModelByAgent[selectedAgent];
setSelectedModel(
defaultModel || (activeModels.length > 0 ? activeModels[0].id : "")
);
setSelectedModel(defaultModel || (activeModels.length > 0 ? activeModels[0].id : ""));
}}
title="Back to model list"
type="button"
@ -272,12 +256,7 @@ const SessionCreateMenu = ({
{activeModes.length > 0 && (
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
title="Mode"
>
<select className="setup-select" value={agentMode} onChange={(e) => setAgentMode(e.target.value)} title="Mode">
{activeModes.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}

View file

@ -20,7 +20,7 @@ const agentLabels: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence";
const MIN_REFRESH_SPIN_MS = 350;
@ -64,9 +64,7 @@ const SessionSidebar = ({
const activeSessions = sessions.filter((session) => !session.archived);
const archivedSessions = sessions.filter((session) => session.archived);
const visibleSessions = showArchived ? archivedSessions : activeSessions;
const orderedVisibleSessions = showArchived
? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended))
: visibleSessions;
const orderedVisibleSessions = showArchived ? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended)) : visibleSessions;
useEffect(() => {
if (!showMenu) return;
@ -114,27 +112,14 @@ const SessionSidebar = ({
onClick={() => setShowArchived((value) => !value)}
title={showArchived ? "Hide archived sessions" : `Show archived sessions (${archivedCount})`}
>
{showArchived ? (
<ArrowLeft size={12} className="button-icon" />
) : (
<Archive size={12} className="button-icon" />
)}
{showArchived ? <ArrowLeft size={12} className="button-icon" /> : <Archive size={12} className="button-icon" />}
</button>
)}
<button
className="button secondary small"
onClick={() => void handleRefresh()}
title="Refresh sessions"
disabled={sessionsLoading || refreshing}
>
<button className="button secondary small" onClick={() => void handleRefresh()} title="Refresh sessions" disabled={sessionsLoading || refreshing}>
<RefreshCw size={12} className={`button-icon ${sessionsLoading || refreshing ? "spinner-icon" : ""}`} />
</button>
<div className="sidebar-add-menu-wrapper" ref={menuRef}>
<button
className="sidebar-add-btn"
onClick={() => setShowMenu((value) => !value)}
title="New session"
>
<button className="sidebar-add-btn" onClick={() => setShowMenu((value) => !value)} title="New session">
<Plus size={14} />
</button>
<SessionCreateMenu
@ -164,31 +149,27 @@ const SessionSidebar = ({
<>
{showArchived && <div className="sidebar-empty">Archived Sessions</div>}
{orderedVisibleSessions.map((session) => (
<div
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
>
<button
className="session-item-content"
onClick={() => onSelectSession(session)}
>
<div className="session-item-id" title={session.sessionId}>
{formatShortId(session.sessionId)}
</div>
<div className="session-item-meta">
<span className="session-item-agent">
{agentLabels[session.agent] ?? session.agent}
</span>
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
</div>
</button>
</div>
))}
<div
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
>
<button className="session-item-content" onClick={() => onSelectSession(session)}>
<div className="session-item-id" title={session.sessionId}>
{formatShortId(session.sessionId)}
</div>
<div className="session-item-meta">
<span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span>
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
</div>
</button>
</div>
))}
</>
)}
</div>
<div className="session-persistence-note">
Sessions are persisted in your browser using IndexedDB. These sessions are only from your browser; your SDK sessions are separate. Adding inspector support for SDK soon.{" "}
Sessions are persisted in your browser using IndexedDB. These sessions are only from your browser; your SDK sessions are separate. Adding inspector
support for SDK soon.{" "}
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer" style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
Configure persistence
<ArrowUpRight size={10} />

View file

@ -17,7 +17,7 @@ import {
Plug,
Shield,
Terminal,
Wrench
Wrench,
} from "lucide-react";
import type { FeatureCoverageView } from "../../types/agents";
@ -39,7 +39,7 @@ const badges = [
{ key: "mcpTools", label: "MCP", icon: Plug },
{ key: "streamingDeltas", label: "Deltas", icon: Activity },
{ key: "itemStarted", label: "Item Start", icon: CircleDot },
{ key: "variants", label: "Variants", icon: Layers }
{ key: "variants", label: "Variants", icon: Layers },
] as const;
type BadgeItem = (typeof badges)[number];

View file

@ -156,9 +156,7 @@ const ChatPanel = ({
{modelLabel}
</span>
)}
{sessionId && currentAgentVersion && (
<span className="header-meta-pill">v{currentAgentVersion}</span>
)}
{sessionId && currentAgentVersion && <span className="header-meta-pill">v{currentAgentVersion}</span>}
{sessionId && (
<button
type="button"
@ -171,11 +169,9 @@ const ChatPanel = ({
)}
</div>
<div className="panel-header-right">
{sessionId && tokenUsage && (
<span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>
)}
{sessionId && (
sessionEnded ? (
{sessionId && tokenUsage && <span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>}
{sessionId &&
(sessionEnded ? (
<>
<span className="button ghost small session-ended-status" title="Session ended">
<CheckSquare size={12} />
@ -192,17 +188,11 @@ const ChatPanel = ({
</button>
</>
) : (
<button
type="button"
className="button ghost small"
onClick={onEndSession}
title="End session"
>
<button type="button" className="button ghost small" onClick={onEndSession} title="End session">
<Square size={12} />
End
</button>
)
)}
))}
</div>
</div>
@ -219,10 +209,7 @@ const ChatPanel = ({
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
<div className="empty-state-menu-wrapper" ref={menuRef}>
<button
className="button primary"
onClick={() => setShowAgentMenu((value) => !value)}
>
<button className="button primary" onClick={() => setShowAgentMenu((value) => !value)}>
<Plus className="button-icon" />
Create Session
</button>

View file

@ -147,7 +147,7 @@ const InspectorConversation = ({
renderPermissionIcon: () => <Shield size={14} />,
renderPermissionOptionContent: ({ option, label, selected }) => (
<>
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
{selected ? option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} /> : null}
{label}
</>
),

View file

@ -14,7 +14,7 @@ const AgentsTab = ({
onRefresh,
onInstall,
loading,
error
error,
}: {
agents: AgentInfo[];
defaultAgents: string[];
@ -60,9 +60,7 @@ const AgentsTab = ({
</div>
{error && <div className="banner error">{error}</div>}
{!loading && agents.length === 0 && (
<div className="card-meta">No agents reported. Click refresh to check.</div>
)}
{!loading && agents.length === 0 && <div className="card-meta">No agents reported. Click refresh to check.</div>}
{(agents.length
? agents
@ -73,16 +71,15 @@ const AgentsTab = ({
version: undefined as string | undefined,
path: undefined as string | undefined,
capabilities: emptyFeatureCoverage as AgentInfo["capabilities"],
}))).map((agent) => {
}))
).map((agent) => {
const isInstalling = installingAgent === agent.id;
return (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<div className="card-header-pills">
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
<span className={`pill ${agent.installed ? "success" : "danger"}`}>{agent.installed ? "Installed" : "Missing"}</span>
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
</span>
@ -90,7 +87,11 @@ const AgentsTab = ({
</div>
<div className="card-meta">
{agent.version ?? "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
{agent.path && (
<span className="mono muted" style={{ marginLeft: 8 }}>
{agent.path}
</span>
)}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
@ -104,16 +105,8 @@ const AgentsTab = ({
</div>
)}
<div className="card-actions">
<button
className="button secondary small"
onClick={() => handleInstall(agent.id, agent.installed)}
disabled={isInstalling}
>
{isInstalling ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Download className="button-icon" />
)}
<button className="button secondary small" onClick={() => handleInstall(agent.id, agent.installed)} disabled={isInstalling}>
{isInstalling ? <Loader2 className="button-icon spinner-icon" /> : <Download className="button-icon" />}
{isInstalling ? "Installing..." : agent.installed ? "Reinstall" : "Install"}
</button>
</div>

View file

@ -59,11 +59,7 @@ const DebugPanel = ({
return (
<div className={`debug-panel ${collapsed ? "collapsed" : ""}`}>
<div className="debug-tabs">
<button
className="debug-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? "Expand panel" : "Collapse panel"}
>
<button className="debug-collapse-btn" onClick={onToggleCollapse} title={collapsed ? "Expand panel" : "Collapse panel"}>
{collapsed ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
<button className={`debug-tab ${debugTab === "events" ? "active" : ""}`} onClick={() => onDebugTabChange("events")}>
@ -98,22 +94,10 @@ const DebugPanel = ({
</div>
<div className="debug-content">
{debugTab === "log" && (
<RequestLogTab
requestLog={requestLog}
copiedLogId={copiedLogId}
onClear={onClearRequestLog}
onCopy={onCopyRequestLog}
/>
)}
{debugTab === "log" && <RequestLogTab requestLog={requestLog} copiedLogId={copiedLogId} onClear={onClearRequestLog} onCopy={onCopyRequestLog} />}
{debugTab === "events" && (
<EventsTab
events={events}
onClear={onResetEvents}
highlightedEventId={highlightedEventId}
onClearHighlight={onClearHighlight}
/>
<EventsTab events={events} onClear={onResetEvents} highlightedEventId={highlightedEventId} onClearHighlight={onClearHighlight} />
)}
{debugTab === "agents" && (
@ -128,21 +112,13 @@ const DebugPanel = ({
/>
)}
{debugTab === "mcp" && (
<McpTab getClient={getClient} />
)}
{debugTab === "mcp" && <McpTab getClient={getClient} />}
{debugTab === "processes" && (
<ProcessesTab getClient={getClient} />
)}
{debugTab === "processes" && <ProcessesTab getClient={getClient} />}
{debugTab === "run-process" && (
<ProcessRunTab getClient={getClient} />
)}
{debugTab === "run-process" && <ProcessRunTab getClient={getClient} />}
{debugTab === "skills" && (
<SkillsTab getClient={getClient} />
)}
{debugTab === "skills" && <SkillsTab getClient={getClient} />}
</div>
</div>
);

View file

@ -125,12 +125,15 @@ const EventsTab = ({
const handleCopy = () => {
const text = JSON.stringify(events, null, 2);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {
fallbackCopy(text);
});
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
@ -188,13 +191,7 @@ const EventsTab = ({
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">{events.length} events</span>
<div className="inline-row">
<button
type="button"
className="button ghost small"
onClick={handleCopy}
disabled={events.length === 0}
title="Copy all events as JSON"
>
<button type="button" className="button ghost small" onClick={handleCopy} disabled={events.length === 0} title="Copy all events as JSON">
{copied ? "Copied" : "Copy JSON"}
</button>
<button className="button ghost small" onClick={onClear}>
@ -204,9 +201,7 @@ const EventsTab = ({
</div>
{events.length === 0 ? (
<div className="card-meta">
No events yet. Create a session and send a message.
</div>
<div className="card-meta">No events yet. Create a session and send a message.</div>
) : (
<div className="event-list">
{[...events].reverse().map((event) => {
@ -215,7 +210,7 @@ const EventsTab = ({
const toggleCollapsed = () =>
setCollapsedEvents((prev) => ({
...prev,
[eventKey]: !(prev[eventKey] ?? true)
[eventKey]: !(prev[eventKey] ?? true),
}));
const method = getMethod(event);
const payload = event.payload as Record<string, unknown>;
@ -231,30 +226,21 @@ const EventsTab = ({
id={`event-${event.id}`}
className={`event-item ${isCollapsed ? "collapsed" : "expanded"} ${isHighlighted ? "highlighted" : ""}`}
>
<button
className="event-summary"
type="button"
onClick={toggleCollapsed}
title={isCollapsed ? "Expand payload" : "Collapse payload"}
>
<button className="event-summary" type="button" onClick={toggleCollapsed} title={isCollapsed ? "Expand payload" : "Collapse payload"}>
<span className={`event-icon ${category}`}>
<Icon size={14} />
</span>
<div className="event-summary-main">
<div className="event-title-row">
<span className={`event-type ${category}`}>{method}</span>
<span className={`pill ${senderClass === "client" ? "accent" : "success"}`}>
{event.sender}
</span>
<span className={`pill ${senderClass === "client" ? "accent" : "success"}`}>{event.sender}</span>
<span className="event-time">{time}</span>
</div>
<div className="event-id" title={event.id}>
{formatShortId(event.id)}
</div>
</div>
<span className="event-chevron">
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</span>
<span className="event-chevron">{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}</span>
</button>
{!isCollapsed && <pre className="code-block event-payload">{formatJson(event.payload)}</pre>}
</div>

View file

@ -10,11 +10,7 @@ type McpEntry = {
const MCP_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-mcp-directory";
const McpTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const McpTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [directory, setDirectory] = useState(() => {
if (typeof window === "undefined") return "/";
try {
@ -35,28 +31,29 @@ const McpTab = ({
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const loadAll = useCallback(async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/mcp.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
const loadAll = useCallback(
async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/mcp.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
setEntries([]);
return;
}
const map = JSON.parse(text) as Record<string, Record<string, unknown>>;
setEntries(Object.entries(map).map(([name, config]) => ({ name, config })));
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
return;
} finally {
setLoading(false);
}
const map = JSON.parse(text) as Record<string, Record<string, unknown>>;
setEntries(
Object.entries(map).map(([name, config]) => ({ name, config })),
);
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
} finally {
setLoading(false);
}
}, [getClient]);
},
[getClient],
);
useEffect(() => {
loadAll(directory);
@ -106,10 +103,7 @@ const McpTab = ({
setSaving(true);
setEditError(null);
try {
await getClient().setMcpConfig(
{ directory, mcpName: name },
parsed as Parameters<SandboxAgent["setMcpConfig"]>[1],
);
await getClient().setMcpConfig({ directory, mcpName: name }, parsed as Parameters<SandboxAgent["setMcpConfig"]>[1]);
cancelEdit();
await loadAll(directory);
} catch (err) {
@ -159,26 +153,34 @@ const McpTab = ({
{editing && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="card-header">
<span className="card-title">
{editName ? `Edit: ${editName}` : "Add MCP Server"}
</span>
<span className="card-title">{editName ? `Edit: ${editName}` : "Add MCP Server"}</span>
</div>
<div style={{ marginTop: 8 }}>
<input
className="setup-input"
value={editName}
onChange={(e) => { setEditName(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditName(e.target.value);
setEditError(null);
}}
placeholder="server-name"
style={{ marginBottom: 8, width: "100%", boxSizing: "border-box" }}
/>
<textarea
className="setup-input mono"
value={editJson}
onChange={(e) => { setEditJson(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditJson(e.target.value);
setEditError(null);
}}
rows={6}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11, resize: "vertical" }}
/>
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
{editError && (
<div className="banner error" style={{ marginTop: 4 }}>
{editError}
</div>
)}
</div>
<div className="card-actions">
<button className="button primary small" onClick={save} disabled={saving}>
@ -192,11 +194,7 @@ const McpTab = ({
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No MCP servers configured in this directory.
</div>
)}
{entries.length === 0 && !editing && !loading && <div className="card-meta">No MCP servers configured in this directory.</div>}
{entries.map((entry) => {
const isCollapsed = collapsedServers[entry.name] ?? true;
@ -215,15 +213,8 @@ const McpTab = ({
<span className="card-title">{entry.name}</span>
</div>
<div className="card-header-pills">
<span className="pill accent">
{(entry.config as { type?: string }).type ?? "unknown"}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<span className="pill accent">{(entry.config as { type?: string }).type ?? "unknown"}</span>
<button className="button ghost small" onClick={() => remove(entry.name)} title="Remove" style={{ padding: "2px 4px" }}>
<Trash2 size={12} />
</button>
</div>

View file

@ -3,13 +3,13 @@ import { useState } from "react";
import { SandboxAgentError } from "sandbox-agent";
import type { ProcessRunResponse, SandboxAgent } from "sandbox-agent";
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
const parseArgs = (value: string): string[] =>
value
.split("\n")
.map((part) => part.trim())
.filter(Boolean);
const ProcessRunTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const ProcessRunTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [cwd, setCwd] = useState("");
@ -91,11 +91,7 @@ const ProcessRunTab = ({
/>
</div>
<button
className="process-advanced-toggle"
onClick={() => setShowAdvanced((prev) => !prev)}
type="button"
>
<button className="process-advanced-toggle" onClick={() => setShowAdvanced((prev) => !prev)} type="button">
{showAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Advanced
</button>

View file

@ -26,7 +26,11 @@ const formatDateTime = (value: number | null | undefined): string => {
return new Date(value).toLocaleString();
};
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
const parseArgs = (value: string): string[] =>
value
.split("\n")
.map((part) => part.trim())
.filter(Boolean);
const formatCommandSummary = (process: Pick<ProcessInfo, "command" | "args">): string => {
return [process.command, ...process.args].join(" ").trim();
@ -36,11 +40,7 @@ const canOpenTerminal = (process: ProcessInfo | null | undefined): boolean => {
return Boolean(process && process.status === "running" && process.interactive && process.tty);
};
const ProcessesTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const ProcessesTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@ -62,63 +62,64 @@ const ProcessesTab = ({
const [terminalOpen, setTerminalOpen] = useState(false);
const [actingProcessId, setActingProcessId] = useState<string | null>(null);
const loadProcesses = useCallback(async (mode: "initial" | "refresh" = "initial") => {
if (mode === "initial") {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
try {
const response = await getClient().listProcesses();
setProcesses(response.processes);
setSelectedProcessId((current) => {
if (!current) {
return response.processes[0]?.id ?? null;
}
return response.processes.some((listedProcess) => listedProcess.id === current)
? current
: response.processes[0]?.id ?? null;
});
} catch (loadError) {
setError(extractErrorMessage(loadError, "Unable to load processes."));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [getClient]);
const loadProcesses = useCallback(
async (mode: "initial" | "refresh" = "initial") => {
if (mode === "initial") {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
try {
const response = await getClient().listProcesses();
setProcesses(response.processes);
setSelectedProcessId((current) => {
if (!current) {
return response.processes[0]?.id ?? null;
}
return response.processes.some((listedProcess) => listedProcess.id === current) ? current : (response.processes[0]?.id ?? null);
});
} catch (loadError) {
setError(extractErrorMessage(loadError, "Unable to load processes."));
} finally {
setLoading(false);
setRefreshing(false);
}
},
[getClient],
);
const loadSelectedLogs = useCallback(async (process: ProcessInfo | null) => {
if (!process) {
setLogsText("");
const loadSelectedLogs = useCallback(
async (process: ProcessInfo | null) => {
if (!process) {
setLogsText("");
setLogsError(null);
return;
}
setLogsLoading(true);
setLogsError(null);
return;
}
setLogsLoading(true);
setLogsError(null);
try {
const response = await getClient().getProcessLogs(process.id, {
stream: process.tty ? "pty" : "combined",
tail: 200,
});
const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join("");
setLogsText(text);
} catch (loadError) {
setLogsError(extractErrorMessage(loadError, "Unable to load process logs."));
setLogsText("");
} finally {
setLogsLoading(false);
}
}, [getClient]);
try {
const response = await getClient().getProcessLogs(process.id, {
stream: process.tty ? "pty" : "combined",
tail: 200,
});
const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join("");
setLogsText(text);
} catch (loadError) {
setLogsError(extractErrorMessage(loadError, "Unable to load process logs."));
setLogsText("");
} finally {
setLogsLoading(false);
}
},
[getClient],
);
useEffect(() => {
void loadProcesses();
}, [loadProcesses]);
const selectedProcess = useMemo(
() => processes.find((process) => process.id === selectedProcessId) ?? null,
[processes, selectedProcessId]
);
const selectedProcess = useMemo(() => processes.find((process) => process.id === selectedProcessId) ?? null, [processes, selectedProcessId]);
useEffect(() => {
void loadSelectedLogs(selectedProcess);
@ -187,11 +188,7 @@ const ProcessesTab = ({
<div className="processes-container">
{/* Create form */}
<div className="processes-section">
<button
className="processes-section-toggle"
onClick={() => setShowCreateForm((prev) => !prev)}
type="button"
>
<button className="processes-section-toggle" onClick={() => setShowCreateForm((prev) => !prev)} type="button">
{showCreateForm ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Create Process</span>
</button>
@ -311,7 +308,9 @@ const ProcessesTab = ({
<span className={`process-status-dot ${process.status}`} />
<span className="process-list-item-cmd mono">{formatCommandSummary(process)}</span>
{process.interactive && process.tty && (
<span className="pill neutral" style={{ fontSize: 9 }}>tty</span>
<span className="pill neutral" style={{ fontSize: 9 }}>
tty
</span>
)}
</div>
<div className="process-list-item-meta">
@ -336,7 +335,10 @@ const ProcessesTab = ({
<>
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "stop"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "stop");
}}
disabled={Boolean(actingProcessId)}
>
{isStopping ? <Loader2 className="button-icon spinner-icon" size={12} /> : null}
@ -344,7 +346,10 @@ const ProcessesTab = ({
</button>
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "kill"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "kill");
}}
disabled={Boolean(actingProcessId)}
>
{isKilling ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Skull className="button-icon" size={12} />}
@ -355,7 +360,10 @@ const ProcessesTab = ({
{process.status === "exited" ? (
<button
className="button secondary small"
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "delete"); }}
onClick={(e) => {
e.stopPropagation();
void handleAction(process.id, "delete");
}}
disabled={Boolean(actingProcessId)}
>
{isDeleting ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Trash2 className="button-icon" size={12} />}
@ -385,30 +393,21 @@ const ProcessesTab = ({
<span>Created: {formatDateTime(selectedProcess.createdAtMs)}</span>
{selectedProcess.exitedAtMs ? <span>Exited: {formatDateTime(selectedProcess.exitedAtMs)}</span> : null}
{selectedProcess.exitCode != null ? <span>Exit code: {selectedProcess.exitCode}</span> : null}
<span className="mono" style={{ opacity: 0.6 }}>{selectedProcess.id}</span>
<span className="mono" style={{ opacity: 0.6 }}>
{selectedProcess.id}
</span>
</div>
{/* Terminal */}
{terminalOpen && canOpenTerminal(selectedProcess) ? (
<ProcessTerminal
client={getClient()}
processId={selectedProcess.id}
style={{ marginTop: 4 }}
onExit={handleTerminalExit}
/>
<ProcessTerminal client={getClient()} processId={selectedProcess.id} style={{ marginTop: 4 }} onExit={handleTerminalExit} />
) : canOpenTerminal(selectedProcess) ? (
<button
className="button secondary small"
onClick={() => setTerminalOpen(true)}
style={{ marginTop: 8 }}
>
<button className="button secondary small" onClick={() => setTerminalOpen(true)} style={{ marginTop: 8 }}>
<SquareTerminal className="button-icon" size={12} />
Open Terminal
</button>
) : selectedProcess.interactive && selectedProcess.tty ? (
<div className="process-terminal-empty">
Terminal available while process is running.
</div>
<div className="process-terminal-empty">Terminal available while process is running.</div>
) : null}
{/* Logs */}

View file

@ -8,7 +8,7 @@ const RequestLogTab = ({
requestLog,
copiedLogId,
onClear,
onCopy
onCopy,
}: {
requestLog: RequestLog[];
copiedLogId: number | null;
@ -49,16 +49,16 @@ const RequestLogTab = ({
<div className="event-summary-main">
<div className="event-title-row">
<span className="log-method">{entry.method}</span>
<span className="log-url text-truncate" style={{ flex: 1 }}>{entry.url}</span>
<span className="log-url text-truncate" style={{ flex: 1 }}>
{entry.url}
</span>
</div>
<div className="event-id">
{entry.time}
{entry.error && ` - ${entry.error}`}
</div>
</div>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</span>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>{entry.status || "ERR"}</span>
<span
className="copy-button"
onClick={(e) => {
@ -77,18 +77,18 @@ const RequestLogTab = ({
<Clipboard size={14} />
{copiedLogId === entry.id ? "Copied" : "curl"}
</span>
{hasDetails && (
<span className="event-chevron">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
)}
{hasDetails && <span className="event-chevron">{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</span>}
</button>
{isExpanded && (
<div className="event-payload" style={{ padding: "8px 12px" }}>
{entry.headers && Object.keys(entry.headers).length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="part-title">Request Headers</div>
<pre className="code-block">{Object.entries(entry.headers).map(([k, v]) => `${k}: ${v}`).join("\n")}</pre>
<pre className="code-block">
{Object.entries(entry.headers)
.map(([k, v]) => `${k}: ${v}`)
.join("\n")}
</pre>
</div>
)}
{entry.body && (

View file

@ -10,11 +10,7 @@ type SkillEntry = {
const SKILLS_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-skills-directory";
const SkillsTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const SkillsTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
const officialSkills = [
{
name: "Sandbox Agent SDK",
@ -27,13 +23,7 @@ const SkillsTab = ({
skillId: "rivet",
source: "rivet-dev/skills",
summary: "Open-source platform for building, deploying, and scaling AI agents.",
features: [
"Session Persistence",
"Resumable Sessions",
"Multi-Agent Support",
"Realtime Events",
"Tool Call Visibility",
],
features: ["Session Persistence", "Resumable Sessions", "Multi-Agent Support", "Realtime Events", "Tool Call Visibility"],
},
];
@ -63,28 +53,29 @@ const SkillsTab = ({
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const loadAll = useCallback(async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/skills.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
const loadAll = useCallback(
async (dir: string) => {
setLoading(true);
setError(null);
try {
const configPath = `${dir === "/" ? "" : dir}/.sandbox-agent/config/skills.json`;
const bytes = await getClient().readFsFile({ path: configPath });
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
setEntries([]);
return;
}
const map = JSON.parse(text) as Record<string, SkillEntry["config"]>;
setEntries(Object.entries(map).map(([name, config]) => ({ name, config })));
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
return;
} finally {
setLoading(false);
}
const map = JSON.parse(text) as Record<string, SkillEntry["config"]>;
setEntries(
Object.entries(map).map(([name, config]) => ({ name, config })),
);
} catch {
// File doesn't exist yet or is empty — that's fine
setEntries([]);
} finally {
setLoading(false);
}
}, [getClient]);
},
[getClient],
);
useEffect(() => {
loadAll(directory);
@ -139,7 +130,10 @@ const SkillsTab = ({
if (editRef.trim()) skillEntry.ref = editRef.trim();
if (editSubpath.trim()) skillEntry.subpath = editSubpath.trim();
const skillsList = editSkills.trim()
? editSkills.split(",").map((s) => s.trim()).filter(Boolean)
? editSkills
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
if (skillsList && skillsList.length > 0) skillEntry.skills = skillsList;
@ -148,10 +142,7 @@ const SkillsTab = ({
setSaving(true);
setEditError(null);
try {
await getClient().setSkillsConfig(
{ directory, skillName: name },
config,
);
await getClient().setSkillsConfig({ directory, skillName: name }, config);
cancelEdit();
await loadAll(directory);
} catch (err) {
@ -197,7 +188,7 @@ const SkillsTab = ({
}
};
const applySkillPreset = (skill: typeof officialSkills[0]) => {
const applySkillPreset = (skill: (typeof officialSkills)[0]) => {
setEditing(true);
setEditName(skill.skillId);
setEditSource(skill.source);
@ -222,12 +213,12 @@ const SkillsTab = ({
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Skills Configuration</span>
<div className="inline-row" style={{ gap: 6 }}>
<button
className="button secondary small"
onClick={() => setShowSdkSkills((prev) => !prev)}
title="Toggle official skills list"
>
{showSdkSkills ? <ChevronDown className="button-icon" style={{ width: 12, height: 12 }} /> : <ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />}
<button className="button secondary small" onClick={() => setShowSdkSkills((prev) => !prev)} title="Toggle official skills list">
{showSdkSkills ? (
<ChevronDown className="button-icon" style={{ width: 12, height: 12 }} />
) : (
<ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />
)}
Official Skills
</button>
{!editing && (
@ -261,7 +252,9 @@ const SkillsTab = ({
{copiedId === `skill-input-${skill.skillId}` ? "Filled" : "Use"}
</button>
</div>
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>{skill.summary}</div>
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>
{skill.summary}
</div>
{skill.features && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{skill.features.map((feature) => (
@ -299,17 +292,15 @@ const SkillsTab = ({
<input
className="setup-input"
value={editName}
onChange={(e) => { setEditName(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditName(e.target.value);
setEditError(null);
}}
placeholder="skill-name"
style={{ marginBottom: 6, width: "100%", boxSizing: "border-box" }}
/>
<div className="inline-row" style={{ marginBottom: 6, gap: 4 }}>
<select
className="setup-select"
value={editType}
onChange={(e) => setEditType(e.target.value)}
style={{ width: 90 }}
>
<select className="setup-select" value={editType} onChange={(e) => setEditType(e.target.value)} style={{ width: 90 }}>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
@ -317,7 +308,10 @@ const SkillsTab = ({
<input
className="setup-input mono"
value={editSource}
onChange={(e) => { setEditSource(e.target.value); setEditError(null); }}
onChange={(e) => {
setEditSource(e.target.value);
setEditError(null);
}}
placeholder={editType === "github" ? "owner/repo" : editType === "local" ? "/path/to/skill" : "https://..."}
style={{ flex: 1 }}
/>
@ -347,7 +341,11 @@ const SkillsTab = ({
/>
</div>
)}
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
{editError && (
<div className="banner error" style={{ marginTop: 4 }}>
{editError}
</div>
)}
</div>
<div className="card-actions">
<button className="button primary small" onClick={save} disabled={saving}>
@ -361,11 +359,7 @@ const SkillsTab = ({
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No skills configured in this directory.
</div>
)}
{entries.length === 0 && !editing && !loading && <div className="card-meta">No skills configured in this directory.</div>}
{entries.map((entry) => {
const isCollapsed = collapsedSkills[entry.name] ?? true;
@ -387,12 +381,7 @@ const SkillsTab = ({
<span className="pill accent">
{entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<button className="button ghost small" onClick={() => remove(entry.name)} title="Remove" style={{ padding: "2px 4px" }}>
<Trash2 size={12} />
</button>
</div>

View file

@ -2,7 +2,7 @@ export const askForLocalNetworkAccess = async (): Promise<boolean> => {
try {
const status = await navigator.permissions.query({
// @ts-expect-error - local-network-access is not in standard types
name: "local-network-access"
name: "local-network-access",
});
if (status.state === "granted") {
return true;

View file

@ -5,5 +5,5 @@ import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);

View file

@ -41,5 +41,5 @@ export const emptyFeatureCoverage: FeatureCoverageView = {
streamingDeltas: false,
itemStarted: false,
variants: false,
sharedProcess: false
sharedProcess: false,
};

View file

@ -9,7 +9,5 @@ export const buildCurl = (method: string, url: string, body?: string, token?: st
headers.push(`-H 'Content-Type: application/json'`);
}
const data = body ? `-d '${escapeSingleQuotes(body)}'` : "";
return `curl -X ${method} ${headers.join(" ")} ${data} '${escapeSingleQuotes(url)}'`
.replace(/\s+/g, " ")
.trim();
return `curl -X ${method} ${headers.join(" ")} ${data} '${escapeSingleQuotes(url)}'`.replace(/\s+/g, " ").trim();
};