mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 03:03:48 +00:00
Configure lefthook formatter checks (#231)
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
This commit is contained in:
parent
0471214d65
commit
d2346bafb3
282 changed files with 5840 additions and 8399 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import App from "./App";
|
|||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -41,5 +41,5 @@ export const emptyFeatureCoverage: FeatureCoverageView = {
|
|||
streamingDeltas: false,
|
||||
itemStarted: false,
|
||||
variants: false,
|
||||
sharedProcess: false
|
||||
sharedProcess: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue