Configure lefthook formatter checks (#231)

* Add lefthook formatter checks

* Fix SDK mode hydration

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

View file

@ -32,9 +32,7 @@ import SessionSidebar from "./components/SessionSidebar";
import type { RequestLog } from "./types/requestLog";
import { buildCurl } from "./utils/http";
const flattenSelectOptions = (
options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>
): ConfigSelectOption[] => {
const flattenSelectOptions = (options: ConfigSelectOption[] | Array<{ group: string; name: string; options: ConfigSelectOption[] }>): ConfigSelectOption[] => {
if (options.length === 0) return [];
if ("value" in options[0]) return options as ConfigSelectOption[];
return (options as Array<{ options: ConfigSelectOption[] }>).flatMap((g) => g.options);
@ -186,9 +184,7 @@ const getPersistedSessionModels = (): Record<string, string> => {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0
)
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0),
);
} catch {
return {};
@ -230,14 +226,12 @@ const getInitialConnection = () => {
}
}
const hasUrlParam = urlParam != null && urlParam.length > 0;
const defaultEndpoint = import.meta.env.DEV
? DEFAULT_ENDPOINT
: (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
const defaultEndpoint = import.meta.env.DEV ? DEFAULT_ENDPOINT : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT);
return {
endpoint: hasUrlParam ? urlParam : defaultEndpoint,
token: tokenParam,
headers,
hasUrlParam
hasUrlParam,
};
};
@ -247,7 +241,7 @@ const agentDisplayNames: Record<string, string> = {
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
cursor: "Cursor",
};
export default function App() {
@ -324,96 +318,94 @@ export default function App() {
});
}, []);
const createClient = useCallback(async (overrideEndpoint?: string) => {
const targetEndpoint = overrideEndpoint ?? endpoint;
const fetchWithLog: typeof fetch = async (input, init) => {
const method = init?.method ?? "GET";
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const bodyText = typeof init?.body === "string" ? init.body : undefined;
const curl = buildCurl(method, url, bodyText, token);
const logId = logIdRef.current++;
const createClient = useCallback(
async (overrideEndpoint?: string) => {
const targetEndpoint = overrideEndpoint ?? endpoint;
const fetchWithLog: typeof fetch = async (input, init) => {
const method = init?.method ?? "GET";
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const bodyText = typeof init?.body === "string" ? init.body : undefined;
const curl = buildCurl(method, url, bodyText, token);
const logId = logIdRef.current++;
const headers: Record<string, string> = {};
if (init?.headers) {
const h = new Headers(init.headers as HeadersInit);
h.forEach((v, k) => { headers[k] = v; });
}
const headers: Record<string, string> = {};
if (init?.headers) {
const h = new Headers(init.headers as HeadersInit);
h.forEach((v, k) => {
headers[k] = v;
});
}
const entry: RequestLog = {
id: logId,
method,
url,
headers,
body: bodyText,
time: new Date().toLocaleTimeString(),
curl
};
let logged = false;
const entry: RequestLog = {
id: logId,
method,
url,
headers,
body: bodyText,
time: new Date().toLocaleTimeString(),
curl,
};
let logged = false;
const fetchInit = {
...init,
targetAddressSpace: "loopback"
};
const fetchInit = {
...init,
targetAddressSpace: "loopback",
};
try {
const response = await fetch(input, fetchInit);
const acceptsStream = headers["accept"]?.includes("text/event-stream");
if (acceptsStream) {
const ct = response.headers.get("content-type") ?? "";
if (!ct.includes("text/event-stream")) {
throw new Error(
`Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})`
);
try {
const response = await fetch(input, fetchInit);
const acceptsStream = headers["accept"]?.includes("text/event-stream");
if (acceptsStream) {
const ct = response.headers.get("content-type") ?? "";
if (!ct.includes("text/event-stream")) {
throw new Error(`Expected text/event-stream from ${method} ${url} but got ${ct || "(no content-type)"} (HTTP ${response.status})`);
}
logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" });
logged = true;
return response;
}
const clone = response.clone();
const responseBody = await clone.text().catch(() => "");
logRequest({ ...entry, status: response.status, responseBody });
if (!response.ok && response.status >= 500) {
const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody);
window.dispatchEvent(new CustomEvent<string>(HTTP_ERROR_EVENT, { detail: messageText }));
}
logRequest({ ...entry, status: response.status, responseBody: "(SSE stream)" });
logged = true;
return response;
} catch (error) {
const messageText = error instanceof Error ? error.message : "Request failed";
if (!logged) {
logRequest({ ...entry, status: 0, error: messageText });
}
throw error;
}
const clone = response.clone();
const responseBody = await clone.text().catch(() => "");
logRequest({ ...entry, status: response.status, responseBody });
if (!response.ok && response.status >= 500) {
const messageText = getHttpErrorMessage(response.status, response.statusText, responseBody);
window.dispatchEvent(new CustomEvent<string>(HTTP_ERROR_EVENT, { detail: messageText }));
}
logged = true;
return response;
} catch (error) {
const messageText = error instanceof Error ? error.message : "Request failed";
if (!logged) {
logRequest({ ...entry, status: 0, error: messageText });
}
throw error;
};
let persist: SessionPersistDriver;
try {
persist = new IndexedDbSessionPersistDriver({
databaseName: "sandbox-agent-inspector",
});
} catch {
persist = new InMemorySessionPersistDriver({
maxSessions: 512,
maxEventsPerSession: 5_000,
});
}
};
let persist: SessionPersistDriver;
try {
persist = new IndexedDbSessionPersistDriver({
databaseName: "sandbox-agent-inspector",
const client = await SandboxAgent.connect({
baseUrl: targetEndpoint,
token: token || undefined,
fetch: fetchWithLog,
headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined,
persist,
});
} catch {
persist = new InMemorySessionPersistDriver({
maxSessions: 512,
maxEventsPerSession: 5_000,
});
}
const client = await SandboxAgent.connect({
baseUrl: targetEndpoint,
token: token || undefined,
fetch: fetchWithLog,
headers: Object.keys(extraHeaders).length > 0 ? extraHeaders : undefined,
persist,
});
clientRef.current = client;
return client;
}, [endpoint, token, extraHeaders, logRequest]);
clientRef.current = client;
return client;
},
[endpoint, token, extraHeaders, logRequest],
);
const getClient = useCallback((): SandboxAgent => {
if (!clientRef.current) {
@ -433,22 +425,25 @@ export default function App() {
setErrorToasts((prev) => prev.filter((toast) => toast.id !== toastId));
}, []);
const scheduleErrorToastDismiss = useCallback((toastId: number, delayMs: number) => {
const existingTimeoutId = toastTimeoutsRef.current.get(toastId);
if (existingTimeoutId != null) {
window.clearTimeout(existingTimeoutId);
toastTimeoutsRef.current.delete(toastId);
}
const scheduleErrorToastDismiss = useCallback(
(toastId: number, delayMs: number) => {
const existingTimeoutId = toastTimeoutsRef.current.get(toastId);
if (existingTimeoutId != null) {
window.clearTimeout(existingTimeoutId);
toastTimeoutsRef.current.delete(toastId);
}
const clampedDelayMs = Math.max(0, delayMs);
const timeoutId = window.setTimeout(() => {
dismissErrorToast(toastId);
}, clampedDelayMs);
const clampedDelayMs = Math.max(0, delayMs);
const timeoutId = window.setTimeout(() => {
dismissErrorToast(toastId);
}, clampedDelayMs);
toastTimeoutsRef.current.set(toastId, timeoutId);
toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs);
toastRemainingMsRef.current.set(toastId, clampedDelayMs);
}, [dismissErrorToast]);
toastTimeoutsRef.current.set(toastId, timeoutId);
toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs);
toastRemainingMsRef.current.set(toastId, clampedDelayMs);
},
[dismissErrorToast],
);
const pauseErrorToastDismiss = useCallback((toastId: number) => {
const expiryMs = toastExpiryRef.current.get(toastId);
@ -464,125 +459,133 @@ export default function App() {
toastExpiryRef.current.delete(toastId);
}, []);
const resumeErrorToastDismiss = useCallback((toastId: number) => {
if (toastTimeoutsRef.current.has(toastId)) return;
const remainingMs = toastRemainingMsRef.current.get(toastId);
if (remainingMs == null) return;
scheduleErrorToastDismiss(toastId, remainingMs);
}, [scheduleErrorToastDismiss]);
const resumeErrorToastDismiss = useCallback(
(toastId: number) => {
if (toastTimeoutsRef.current.has(toastId)) return;
const remainingMs = toastRemainingMsRef.current.get(toastId);
if (remainingMs == null) return;
scheduleErrorToastDismiss(toastId, remainingMs);
},
[scheduleErrorToastDismiss],
);
const pushErrorToast = useCallback((error: unknown, fallback: string) => {
const messageText = getErrorMessage(error, fallback).trim() || fallback;
const toastId = toastIdRef.current++;
setErrorToasts((prev) => {
if (prev.some((toast) => toast.message === messageText)) {
return prev;
}
return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS);
});
scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS);
}, [scheduleErrorToastDismiss]);
// Subscribe to events for the current active session
const subscribeToSession = useCallback((session: Session) => {
const generation = ++subscriptionGenerationRef.current;
const isCurrentSubscription = (): boolean =>
subscriptionGenerationRef.current === generation
&& activeSessionRef.current?.id === session.id
&& selectedSessionIdRef.current === session.id;
// Unsubscribe from previous
if (eventUnsubRef.current) {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
activeSessionRef.current = session;
const cachedEvents = sessionEventsCacheRef.current.get(session.id);
if (cachedEvents && isCurrentSubscription()) {
setEvents(cachedEvents);
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
} else if (isCurrentSubscription()) {
setHistoryLoadingSessionId(session.id);
}
// Hydrate existing events from persistence
const hydrateEvents = async () => {
const allEvents: SessionEvent[] = [];
let cursor: string | undefined;
while (true) {
const page = await getClient().getEvents({
sessionId: session.id,
cursor,
limit: 250,
});
allEvents.push(...page.items);
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
sessionEventsCacheRef.current.set(session.id, allEvents);
if (!isCurrentSubscription()) return;
setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents));
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
};
hydrateEvents().catch((error) => {
console.error("Failed to hydrate events:", error);
if (isCurrentSubscription()) {
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
}
});
// Subscribe to new events
const unsub = session.onEvent((event) => {
if (!isCurrentSubscription()) return;
setEvents((prev) => {
if (prev.some((existing) => existing.id === event.id)) {
const pushErrorToast = useCallback(
(error: unknown, fallback: string) => {
const messageText = getErrorMessage(error, fallback).trim() || fallback;
const toastId = toastIdRef.current++;
setErrorToasts((prev) => {
if (prev.some((toast) => toast.message === messageText)) {
return prev;
}
const next = [...prev, event];
sessionEventsCacheRef.current.set(session.id, next);
return next;
return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS);
});
});
eventUnsubRef.current = unsub;
scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS);
},
[scheduleErrorToastDismiss],
);
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
// Subscribe to events for the current active session
const subscribeToSession = useCallback(
(session: Session) => {
const generation = ++subscriptionGenerationRef.current;
const isCurrentSubscription = (): boolean =>
subscriptionGenerationRef.current === generation && activeSessionRef.current?.id === session.id && selectedSessionIdRef.current === session.id;
// Unsubscribe from previous
if (eventUnsubRef.current) {
eventUnsubRef.current();
eventUnsubRef.current = null;
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
}, [getClient]);
const handlePermissionReply = useCallback(async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" :
reply === "once" ? o.kind === "allow_once" :
o.kind === "reject_once" || o.kind === "reject_always"
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
activeSessionRef.current = session;
const cachedEvents = sessionEventsCacheRef.current.get(session.id);
if (cachedEvents && isCurrentSubscription()) {
setEvents(cachedEvents);
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
} else if (isCurrentSubscription()) {
setHistoryLoadingSessionId(session.id);
}
// Hydrate existing events from persistence
const hydrateEvents = async () => {
const allEvents: SessionEvent[] = [];
let cursor: string | undefined;
while (true) {
const page = await getClient().getEvents({
sessionId: session.id,
cursor,
limit: 250,
});
allEvents.push(...page.items);
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
sessionEventsCacheRef.current.set(session.id, allEvents);
if (!isCurrentSubscription()) return;
setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents));
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
};
hydrateEvents().catch((error) => {
console.error("Failed to hydrate events:", error);
if (isCurrentSubscription()) {
setHistoryLoadingSessionId((current) => (current === session.id ? null : current));
}
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
}, [pushErrorToast]);
// Subscribe to new events
const unsub = session.onEvent((event) => {
if (!isCurrentSubscription()) return;
setEvents((prev) => {
if (prev.some((existing) => existing.id === event.id)) {
return prev;
}
const next = [...prev, event];
sessionEventsCacheRef.current.set(session.id, next);
return next;
});
});
eventUnsubRef.current = unsub;
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
},
[getClient],
);
const handlePermissionReply = useCallback(
async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" : reply === "once" ? o.kind === "allow_once" : o.kind === "reject_once" || o.kind === "reject_always",
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
},
[pushErrorToast],
);
const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => {
setConnecting(true);
@ -689,19 +692,20 @@ export default function App() {
}
};
const loadAgentConfig = useCallback(async (targetAgentId: string) => {
console.log("[loadAgentConfig] Loading config for agent:", targetAgentId);
try {
const info = await getClient().getAgent(targetAgentId, { config: true });
console.log("[loadAgentConfig] Got agent info:", info);
setAgents((prev) =>
prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a))
);
} catch (error) {
console.error("[loadAgentConfig] Failed to load config:", error);
// Config loading is best-effort; the menu still works without it.
}
}, [getClient]);
const loadAgentConfig = useCallback(
async (targetAgentId: string) => {
console.log("[loadAgentConfig] Loading config for agent:", targetAgentId);
try {
const info = await getClient().getAgent(targetAgentId, { config: true });
console.log("[loadAgentConfig] Got agent info:", info);
setAgents((prev) => prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a)));
} catch (error) {
console.error("[loadAgentConfig] Failed to load config:", error);
// Config loading is best-effort; the menu still works without it.
}
},
[getClient],
);
const fetchSessions = async () => {
setSessionsLoading(true);
@ -743,13 +747,7 @@ export default function App() {
// If the server already considers the session gone, still archive in local UI.
console.warn("Destroy session returned an error while archiving:", error);
}
setSessions((prev) =>
prev.map((session) =>
session.sessionId === targetSessionId
? { ...session, archived: true, ended: true }
: session
)
);
setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: true, ended: true } : session)));
setSessionModelById((prev) => {
if (!(targetSessionId in prev)) return prev;
const next = { ...prev };
@ -764,11 +762,7 @@ export default function App() {
const unarchiveSession = async (targetSessionId: string) => {
unarchiveSessionId(targetSessionId);
setSessions((prev) =>
prev.map((session) =>
session.sessionId === targetSessionId ? { ...session, archived: false } : session
)
);
setSessions((prev) => prev.map((session) => (session.sessionId === targetSessionId ? { ...session, archived: false } : session)));
await fetchSessions();
};
@ -883,7 +877,7 @@ export default function App() {
try {
const agentInfo = agents.find((agent) => agent.id === nextAgentId);
const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find(
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string",
);
if (modelOption && config.model !== modelOption.currentValue) {
await session.rawSend("session/set_config_option", {
@ -951,9 +945,12 @@ export default function App() {
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
fallbackCopy(text, onSuccess);
});
navigator.clipboard
.writeText(text)
.then(onSuccess)
.catch(() => {
fallbackCopy(text, onSuccess);
});
} else {
fallbackCopy(text, onSuccess);
}
@ -1222,18 +1219,15 @@ export default function App() {
}
if (event.sender === "agent" && method === "session/request_permission") {
const params = payload.params as {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
} | undefined;
const params = payload.params as
| {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
}
| undefined;
const toolCallId = params?.toolCall?.toolCallId;
const sdkPermissionId = toolCallId
? permissionToolCallToIdRef.current.get(toolCallId)
: undefined;
const permissionId = sdkPermissionId
?? (typeof payload.id === "number" || typeof payload.id === "string"
? String(payload.id)
: event.id);
const sdkPermissionId = toolCallId ? permissionToolCallToIdRef.current.get(toolCallId) : undefined;
const permissionId = sdkPermissionId ?? (typeof payload.id === "number" || typeof payload.id === "string" ? String(payload.id) : event.id);
const options = (params?.options ?? []).map((o) => ({
optionId: o.optionId,
name: o.name,
@ -1306,12 +1300,7 @@ export default function App() {
const shouldIgnoreCreateNoise = (value: unknown): boolean => {
if (Date.now() > createNoiseIgnoreUntilRef.current) return false;
const message = getErrorMessage(value, "").trim().toLowerCase();
return (
message.length === 0 ||
message === "request failed" ||
message.includes("request failed") ||
message.includes("unhandled promise rejection")
);
return message.length === 0 || message === "request failed" || message.includes("request failed") || message.includes("unhandled promise rejection");
};
const handleWindowError = (event: ErrorEvent) => {
@ -1427,18 +1416,22 @@ export default function App() {
const requestedSessionId = sessionId;
resumeInFlightSessionIdRef.current = requestedSessionId;
getClient().resumeSession(requestedSessionId).then((session) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
subscribeToSession(session);
}).catch((error) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
setSessionError(getErrorMessage(error, "Unable to resume session"));
setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current));
}).finally(() => {
if (resumeInFlightSessionIdRef.current === requestedSessionId) {
resumeInFlightSessionIdRef.current = null;
}
});
getClient()
.resumeSession(requestedSessionId)
.then((session) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
subscribeToSession(session);
})
.catch((error) => {
if (selectedSessionIdRef.current !== requestedSessionId) return;
setSessionError(getErrorMessage(error, "Unable to resume session"));
setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current));
})
.finally(() => {
if (resumeInFlightSessionIdRef.current === requestedSessionId) {
resumeInFlightSessionIdRef.current = null;
}
});
}, [connected, sessionId, sessions, getClient, subscribeToSession]);
useEffect(() => {
@ -1458,9 +1451,7 @@ export default function App() {
// If actively sending a prompt, show thinking
if (sendingSessionId === sessionId) return true;
// Check for in-progress tool calls
const hasInProgressTool = transcriptEntries.some(
(e) => e.kind === "tool" && e.toolStatus === "in_progress"
);
const hasInProgressTool = transcriptEntries.some((e) => e.kind === "tool" && e.toolStatus === "in_progress");
if (hasInProgressTool) return true;
// Check if last message was from user with no subsequent agent activity
const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user");
@ -1468,14 +1459,10 @@ export default function App() {
// If user message is the very last entry, we're waiting for response
if (lastUserMessageIndex === 0) return true;
// Check if there's any agent response after the user message
const entriesAfterUser = transcriptEntries.slice(-(lastUserMessageIndex));
const hasAgentResponse = entriesAfterUser.some(
(e) => e.kind === "message" && e.role === "assistant"
);
const entriesAfterUser = transcriptEntries.slice(-lastUserMessageIndex);
const hasAgentResponse = entriesAfterUser.some((e) => e.kind === "message" && e.role === "assistant");
// If no assistant message after user, but there are completed tools, not thinking
const hasCompletedTools = entriesAfterUser.some(
(e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed")
);
const hasCompletedTools = entriesAfterUser.some((e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed"));
if (!hasAgentResponse && !hasCompletedTools) return true;
return false;
}, [sessionId, sessionEnded, transcriptEntries, sendingSessionId]);
@ -1560,20 +1547,21 @@ export default function App() {
const update = params?.update as Record<string, unknown> | undefined;
if (update?.sessionUpdate !== "config_option_update") continue;
const category = (update.category as string | undefined)
?? ((update.option as Record<string, unknown> | undefined)?.category as string | undefined);
const category = (update.category as string | undefined) ?? ((update.option as Record<string, unknown> | undefined)?.category as string | undefined);
if (category && category !== "model") continue;
const optionId = (update.optionId as string | undefined)
?? (update.configOptionId as string | undefined)
?? ((update.option as Record<string, unknown> | undefined)?.id as string | undefined);
const optionId =
(update.optionId as string | undefined) ??
(update.configOptionId as string | undefined) ??
((update.option as Record<string, unknown> | undefined)?.id as string | undefined);
const seemsModelOption = !optionId || optionId.toLowerCase().includes("model");
if (!seemsModelOption) continue;
const candidate = (update.value as string | undefined)
?? (update.currentValue as string | undefined)
?? (update.selectedValue as string | undefined)
?? (update.modelId as string | undefined);
const candidate =
(update.value as string | undefined) ??
(update.currentValue as string | undefined) ??
(update.selectedValue as string | undefined) ??
(update.modelId as string | undefined);
if (candidate) {
latestModelId = candidate;
}
@ -1594,9 +1582,7 @@ export default function App() {
const optionId = params?.optionId as string | undefined;
const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model"));
if (!seemsModelOption) continue;
const candidate = (params?.value as string | undefined)
?? (params?.currentValue as string | undefined)
?? (params?.modelId as string | undefined);
const candidate = (params?.value as string | undefined) ?? (params?.currentValue as string | undefined) ?? (params?.modelId as string | undefined);
if (candidate) {
latestModelId = candidate;
}
@ -1608,18 +1594,14 @@ export default function App() {
const modelPillLabel = useMemo(() => {
const sessionModelId =
currentSessionModelId
?? (sessionId ? sessionModelById[sessionId] : undefined)
?? (sessionId ? defaultModelByAgent[agentId] : undefined);
currentSessionModelId ?? (sessionId ? sessionModelById[sessionId] : undefined) ?? (sessionId ? defaultModelByAgent[agentId] : undefined);
if (!sessionModelId) return null;
return sessionModelId;
}, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]);
useEffect(() => {
if (!sessionId || !currentSessionModelId) return;
setSessionModelById((prev) =>
prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }
);
setSessionModelById((prev) => (prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }));
}, [currentSessionModelId, sessionId]);
useEffect(() => {
@ -1649,12 +1631,7 @@ export default function App() {
onFocus={() => pauseErrorToastDismiss(toast.id)}
onBlur={() => resumeErrorToastDismiss(toast.id)}
>
<button
type="button"
className="toast-close"
aria-label="Dismiss error"
onClick={() => dismissErrorToast(toast.id)}
>
<button type="button" className="toast-close" aria-label="Dismiss error" onClick={() => dismissErrorToast(toast.id)}>
×
</button>
<div className="toast-content">
@ -1690,7 +1667,7 @@ export default function App() {
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: "20px", width: "auto" }} />
<span className="header-endpoint">{endpoint}</span>
</div>
<div className="header-right">
@ -1699,11 +1676,15 @@ export default function App() {
Docs
</a>
<a className="header-link" href={discordUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</a>
<a className="header-link" href={issueTrackerUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
Issues
</a>
<button className="button secondary small" onClick={disconnect}>
@ -1720,12 +1701,16 @@ export default function App() {
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
@ -1746,12 +1731,16 @@ export default function App() {
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={loadAgentConfig}
agents={agents.length ? agents : defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))}
agents={
agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: true,
capabilities: {} as AgentInfo["capabilities"],
}))
}
agentsLoading={agentsLoading}
agentsError={agentsError}
messagesEndRef={messagesEndRef}

View file

@ -30,7 +30,7 @@ const ConnectScreen = ({
<div className="app">
<header className="header">
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: "20px", width: "auto" }} />
</div>
{(docsUrl || discordUrl || reportUrl) && (
<div className="header-right">
@ -42,13 +42,17 @@ const ConnectScreen = ({
)}
{discordUrl && (
<a className="header-link" href={discordUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</a>
)}
{reportUrl && (
<a className="header-link" href={reportUrl} target="_blank" rel="noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
Issues
</a>
)}
@ -59,7 +63,7 @@ const ConnectScreen = ({
<main className="landing">
<div className="landing-container">
<div className="landing-hero">
<img src={logoUrl} alt="Sandbox Agent" style={{ height: '32px', width: 'auto', marginBottom: '20px' }} />
<img src={logoUrl} alt="Sandbox Agent" style={{ height: "32px", width: "auto", marginBottom: "20px" }} />
</div>
<div className="connect-card">
@ -67,17 +71,15 @@ const ConnectScreen = ({
{connectError && <div className="banner error">{connectError}</div>}
{isHttpsToHttpConnection(window.location.href, endpoint) &&
isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires{" "}
<strong>local network access</strong> permission. Your browser may prompt you to
allow this connection.
</span>
</div>
)}
{isHttpsToHttpConnection(window.location.href, endpoint) && isLocalNetworkTarget(endpoint) && (
<div className="banner warning">
<AlertTriangle size={16} />
<span>
Connecting from HTTPS to a local HTTP server requires <strong>local network access</strong> permission. Your browser may prompt you to allow
this connection.
</span>
</div>
)}
<label className="field">
<span className="label">Endpoint</span>
@ -92,13 +94,7 @@ const ConnectScreen = ({
<label className="field">
<span className="label">Token (optional)</span>
<input
className="input"
type="password"
placeholder="Bearer token"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
/>
<input className="input" type="password" placeholder="Bearer token" value={token} onChange={(event) => onTokenChange(event.target.value)} />
</label>
<button className="button primary" onClick={onConnect} disabled={connecting}>
@ -116,7 +112,11 @@ const ConnectScreen = ({
</button>
<p className="hint">
Having trouble connecting? See the <a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">CORS documentation</a>.
Having trouble connecting? See the{" "}
<a href="https://sandboxagent.dev/docs/cors" target="_blank" rel="noreferrer">
CORS documentation
</a>
.
</p>
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,10 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: 'https://sandbox-agent.dev',
output: 'static',
integrations: [
react(),
tailwind(),
sitemap()
]
site: "https://sandbox-agent.dev",
output: "static",
integrations: [react(), tailwind(), sitemap()],
});

View file

@ -1,42 +1,40 @@
'use client';
"use client";
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
const faqs = [
{
question: 'Does this replace the Vercel AI SDK?',
question: "Does this replace the Vercel AI SDK?",
answer:
"No, they're complementary. AI SDK is for building chat interfaces and calling LLMs. This SDK is for controlling autonomous coding agents that write code and run commands. Use AI SDK for your UI, use this when you need a coding agent to actually code.",
},
{
question: 'Which coding agents are supported?',
answer:
'Claude Code, Codex, OpenCode, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code.',
question: "Which coding agents are supported?",
answer: "Claude Code, Codex, OpenCode, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code.",
},
{
question: 'How is session data persisted?',
question: "How is session data persisted?",
answer:
"This SDK does not handle persisting session data. Events stream in a universal JSON schema that you can persist anywhere. Consider using Postgres or <a href='https://rivet.gg' target='_blank' rel='noopener noreferrer' class='text-orange-400 hover:underline'>Rivet Actors</a> for data persistence.",
},
{
question: 'Can I run this locally or does it require a sandbox provider?',
answer:
'Both. Run locally for development, deploy to E2B, Daytona, or Vercel Sandboxes for production.',
question: "Can I run this locally or does it require a sandbox provider?",
answer: "Both. Run locally for development, deploy to E2B, Daytona, or Vercel Sandboxes for production.",
},
{
question: 'Does it support [platform]?',
question: "Does it support [platform]?",
answer:
"The server is a single Rust binary that runs anywhere with a curl install. If your platform can run Linux binaries (Docker, VMs, etc.), it works. See the deployment guides for E2B, Daytona, and Vercel Sandboxes.",
},
{
question: 'Can I use this with my personal API keys?',
question: "Can I use this with my personal API keys?",
answer:
"Yes. Use <code>sandbox-agent credentials extract-env</code> to extract API keys from your local agent configs (Claude Code, Codex, OpenCode, Amp, Pi) and pass them to the sandbox environment.",
},
{
question: 'Why Rust and not [language]?',
question: "Why Rust and not [language]?",
answer:
"Rust gives us a single static binary, fast startup, and predictable memory usage. That makes it easy to run inside sandboxes or in CI without shipping a large runtime, such as Node.js.",
},
@ -51,7 +49,7 @@ const faqs = [
"Official SDKs assume local execution. They spawn processes and expect interactive terminals. This SDK runs a server inside a sandbox that you connect to over HTTP — designed for remote control from the start.",
},
{
question: 'Why not just SSH into the sandbox?',
question: "Why not just SSH into the sandbox?",
answer:
"Coding agents expect interactive terminals with proper TTY handling. SSH with piped commands breaks tool confirmations, streaming output, and human-in-the-loop flows. The SDK handles all of this over a clean HTTP API.",
},
@ -62,22 +60,15 @@ function FAQItem({ question, answer }: { question: string; answer: string }) {
return (
<div className="border-t border-white/10 first:border-t-0">
<button
onClick={() => setIsOpen(!isOpen)}
className="group flex w-full items-center justify-between py-5 text-left"
>
<button onClick={() => setIsOpen(!isOpen)} className="group flex w-full items-center justify-between py-5 text-left">
<span className="text-base font-normal text-white pr-4 group-hover:text-zinc-300 transition-colors">{question}</span>
<ChevronDown
className={`h-4 w-4 shrink-0 text-zinc-500 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
<ChevronDown className={`h-4 w-4 shrink-0 text-zinc-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"

View file

@ -1,7 +1,7 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { Workflow, Server, Database, Download, Globe, Plug } from 'lucide-react';
import { motion } from "framer-motion";
import { Workflow, Server, Database, Download, Globe, Plug } from "lucide-react";
export function FeatureGrid() {
return (
@ -44,8 +44,7 @@ export function FeatureGrid() {
<h4 className="text-base font-normal text-white">Universal Agent API</h4>
</div>
<p className="text-zinc-500 leading-relaxed text-base max-w-2xl">
Claude Code, Codex, OpenCode, and Amp each have different APIs. We provide a single,
unified interface to control them all.
Claude Code, Codex, OpenCode, and Amp each have different APIs. We provide a single, unified interface to control them all.
</p>
</div>
@ -108,11 +107,11 @@ export function FeatureGrid() {
<Plug className="h-4 w-4" />
</div>
<h4 className="text-base font-normal text-white">OpenCode Support</h4>
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-500 transition-colors group-hover:text-pink-400 group-hover:border-pink-400/30">Experimental</span>
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-500 transition-colors group-hover:text-pink-400 group-hover:border-pink-400/30">
Experimental
</span>
</div>
<p className="text-zinc-500 text-sm leading-relaxed">
Connect OpenCode CLI, SDK, or web UI to control agents through familiar OpenCode tooling.
</p>
<p className="text-zinc-500 text-sm leading-relaxed">Connect OpenCode CLI, SDK, or web UI to control agents through familiar OpenCode tooling.</p>
</div>
</motion.div>
</div>

View file

@ -1,26 +1,26 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { motion } from "framer-motion";
const footer = {
products: [
{ name: 'Actors', href: 'https://rivet.dev/docs/actors' },
{ name: 'Sandbox Agent SDK', href: '/docs' },
{ name: "Actors", href: "https://rivet.dev/docs/actors" },
{ name: "Sandbox Agent SDK", href: "/docs" },
],
developers: [
{ name: 'Documentation', href: '/docs' },
{ name: 'Changelog', href: 'https://github.com/rivet-dev/sandbox-agent/releases' },
{ name: 'Blog', href: 'https://www.rivet.dev/blog/' },
{ name: "Documentation", href: "/docs" },
{ name: "Changelog", href: "https://github.com/rivet-dev/sandbox-agent/releases" },
{ name: "Blog", href: "https://www.rivet.dev/blog/" },
],
legal: [
{ name: 'Terms', href: 'https://rivet.dev/terms' },
{ name: 'Privacy Policy', href: 'https://rivet.dev/privacy' },
{ name: 'Acceptable Use', href: 'https://rivet.dev/acceptable-use' },
{ name: "Terms", href: "https://rivet.dev/terms" },
{ name: "Privacy Policy", href: "https://rivet.dev/privacy" },
{ name: "Acceptable Use", href: "https://rivet.dev/acceptable-use" },
],
social: [
{
name: 'Discord',
href: 'https://discord.gg/auCecybynK',
name: "Discord",
href: "https://discord.gg/auCecybynK",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<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.096 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.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
@ -28,8 +28,8 @@ const footer = {
),
},
{
name: 'GitHub',
href: 'https://github.com/rivet-dev/sandbox-agent',
name: "GitHub",
href: "https://github.com/rivet-dev/sandbox-agent",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
@ -37,8 +37,8 @@ const footer = {
),
},
{
name: 'Twitter',
href: 'https://x.com/rivet_dev',
name: "Twitter",
href: "https://x.com/rivet_dev",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
@ -64,18 +64,10 @@ export function Footer() {
<a href="https://rivet.dev" className="inline-block">
<img src="/rivet-logo-text-white.svg" alt="Rivet" className="h-6 w-auto opacity-90 hover:opacity-100 transition-opacity" />
</a>
<p className="text-sm leading-6 text-zinc-500">
Infrastructure for software that thinks
</p>
<p className="text-sm leading-6 text-zinc-500">Infrastructure for software that thinks</p>
<div className="flex space-x-4">
{footer.social.map((item) => (
<a
key={item.name}
href={item.href}
className="text-zinc-500 hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<a key={item.name} href={item.href} className="text-zinc-500 hover:text-white transition-colors" target="_blank" rel="noopener noreferrer">
<span className="sr-only">{item.name}</span>
{item.icon}
</a>
@ -85,20 +77,12 @@ export function Footer() {
{/* Links */}
<div className="mt-12 grid grid-cols-2 gap-8 md:grid-cols-3 xl:col-span-8 xl:mt-0">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.1 }}>
<h3 className="text-sm font-semibold leading-6 text-white">Products</h3>
<ul role="list" className="mt-4 space-y-3">
{footer.products.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors"
>
<a href={item.href} className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors">
{item.name}
</a>
</li>
@ -116,10 +100,7 @@ export function Footer() {
<ul role="list" className="mt-4 space-y-3">
{footer.developers.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors"
>
<a href={item.href} className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors">
{item.name}
</a>
</li>
@ -127,20 +108,12 @@ export function Footer() {
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.2 }}>
<h3 className="text-sm font-semibold leading-6 text-white">Legal</h3>
<ul role="list" className="mt-4 space-y-3">
{footer.legal.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors"
>
<a href={item.href} className="text-sm leading-6 text-zinc-500 hover:text-white transition-colors">
{item.name}
</a>
</li>
@ -158,9 +131,7 @@ export function Footer() {
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-12 border-t border-white/10 pt-8"
>
<p className="text-xs text-zinc-600 text-center">
&copy; {new Date().getFullYear()} Rivet Gaming, Inc. All rights reserved.
</p>
<p className="text-xs text-zinc-600 text-center">&copy; {new Date().getFullYear()} Rivet Gaming, Inc. All rights reserved.</p>
</motion.div>
</div>
</footer>

View file

@ -1,8 +1,8 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { Code, Server, GitBranch } from 'lucide-react';
import { CopyButton } from './ui/CopyButton';
import { motion } from "framer-motion";
import { Code, Server, GitBranch } from "lucide-react";
import { CopyButton } from "./ui/CopyButton";
const sdkCodeRaw = `import { SandboxAgent } from "sandbox-agent";

View file

@ -1,6 +1,6 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
interface GitHubStarsProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
repo?: string;
@ -13,11 +13,7 @@ function formatNumber(num: number): string {
return num.toString();
}
export function GitHubStars({
repo = 'rivet-dev/sandbox-agent',
className,
...props
}: GitHubStarsProps) {
export function GitHubStars({ repo = "rivet-dev/sandbox-agent", className, ...props }: GitHubStarsProps) {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
@ -35,7 +31,7 @@ export function GitHubStars({
fetch(`https://api.github.com/repos/${repo}`)
.then((response) => {
if (!response.ok) throw new Error('Failed to fetch');
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
})
.then((data) => {
@ -50,29 +46,16 @@ export function GitHubStars({
);
})
.catch((err) => {
console.error('Failed to fetch stars', err);
console.error("Failed to fetch stars", err);
});
}, [repo]);
return (
<a
href={`https://github.com/${repo}`}
target="_blank"
rel="noreferrer"
className={className}
{...props}
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<a href={`https://github.com/${repo}`} target="_blank" rel="noreferrer" className={className} {...props}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span className="hidden md:inline">
{stars ? `${formatNumber(stars)} Stars` : 'GitHub'}
</span>
<span className="hidden md:inline">{stars ? `${formatNumber(stars)} Stars` : "GitHub"}</span>
</a>
);
}

View file

@ -1,15 +1,15 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Terminal, Check, ArrowRight } from 'lucide-react';
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Terminal, Check, ArrowRight } from "lucide-react";
const ADAPTERS = [
{ label: 'Claude Code', color: '#D97757', x: 20, y: 70, logo: '/logos/claude.svg' },
{ label: 'Codex', color: '#10A37F', x: 132, y: 70, logo: 'openai' },
{ label: 'Pi', color: '#06B6D4', x: 244, y: 70, logo: 'pi' },
{ label: 'Amp', color: '#F59E0B', x: 76, y: 155, logo: '/logos/amp.svg' },
{ label: 'OpenCode', color: '#8B5CF6', x: 188, y: 155, logo: 'opencode' },
{ label: "Claude Code", color: "#D97757", x: 20, y: 70, logo: "/logos/claude.svg" },
{ label: "Codex", color: "#10A37F", x: 132, y: 70, logo: "openai" },
{ label: "Pi", color: "#06B6D4", x: 244, y: 70, logo: "pi" },
{ label: "Amp", color: "#F59E0B", x: 76, y: 155, logo: "/logos/amp.svg" },
{ label: "OpenCode", color: "#8B5CF6", x: 188, y: 155, logo: "opencode" },
];
function UniversalAPIDiagram() {
@ -29,7 +29,7 @@ function UniversalAPIDiagram() {
className="absolute inset-0 opacity-[0.15] pointer-events-none transition-all duration-1000"
style={{
backgroundImage: `radial-gradient(circle, ${ADAPTERS[activeIndex].color} 1px, transparent 1px)`,
backgroundSize: '24px 24px',
backgroundSize: "24px 24px",
}}
/>
@ -46,9 +46,7 @@ function UniversalAPIDiagram() {
{/* YOUR APP NODE - Glass dark effect with backdrop blur */}
<foreignObject x="60" y="175" width="180" height="100">
<div
className="w-full h-full rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md flex items-center justify-center"
>
<div className="w-full h-full rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md flex items-center justify-center">
<span className="text-white text-xl font-bold">Your App</span>
</div>
</foreignObject>
@ -72,80 +70,73 @@ function UniversalAPIDiagram() {
{/* SANDBOX BOUNDARY - Glass dark effect with backdrop blur */}
<foreignObject x="360" y="45" width="410" height="360">
<div className="w-full h-full rounded-3xl border border-white/10 bg-black/40 backdrop-blur-md">
<div className="text-white text-sm font-extrabold tracking-[0.2em] text-center pt-4">
SANDBOX
</div>
<div className="text-white text-sm font-extrabold tracking-[0.2em] text-center pt-4">SANDBOX</div>
</div>
</foreignObject>
{/* SANDBOX AGENT SDK */}
<g transform="translate(385, 110)">
<rect width="360" height="270" rx="20" fill="rgba(0,0,0,0.4)" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
<text x="180" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800">
Sandbox Agent Server
</text>
<rect width="360" height="270" rx="20" fill="rgba(0,0,0,0.4)" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
<text x="180" y="35" fill="#FFFFFF" textAnchor="middle" fontSize="18" fontWeight="800">
Sandbox Agent Server
</text>
{/* PROVIDER ADAPTERS */}
{ADAPTERS.map((p, i) => {
const isActive = i === activeIndex;
return (
<g key={i} transform={`translate(${p.x}, ${p.y})`}>
<rect
width="95"
height="58"
rx="10"
fill={isActive ? '#1A1A1E' : '#111'}
stroke={isActive ? p.color : '#333'}
strokeWidth={isActive ? 2 : 1.5}
/>
<g opacity={isActive ? 1 : 0.4}>
{p.logo === 'openai' ? (
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" />
</svg>
) : p.logo === 'opencode' ? (
<svg x="38.5" y="8" width="17" height="22" viewBox="0 0 32 40" fill="none">
<path d="M24 32H8V16H24V32Z" fill="#4B4646"/>
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC"/>
</svg>
) : p.logo === 'pi' ? (
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 800 800" fill="none">
<path fill="#fff" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/>
<path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z"/>
</svg>
) : (
<image href={p.logo} x="36.75" y="8" width="22" height="22" filter="url(#invert-white)" />
)}
</g>
<text
x="47.5"
y="46"
fill="#FFFFFF"
textAnchor="middle"
fontSize="10"
fontWeight="600"
opacity={isActive ? 1 : 0.4}
>
{p.label}
</text>
{/* PROVIDER ADAPTERS */}
{ADAPTERS.map((p, i) => {
const isActive = i === activeIndex;
return (
<g key={i} transform={`translate(${p.x}, ${p.y})`}>
<rect
width="95"
height="58"
rx="10"
fill={isActive ? "#1A1A1E" : "#111"}
stroke={isActive ? p.color : "#333"}
strokeWidth={isActive ? 2 : 1.5}
/>
<g opacity={isActive ? 1 : 0.4}>
{p.logo === "openai" ? (
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 24 24" fill="none">
<path
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
fill="#ffffff"
/>
</svg>
) : p.logo === "opencode" ? (
<svg x="38.5" y="8" width="17" height="22" viewBox="0 0 32 40" fill="none">
<path d="M24 32H8V16H24V32Z" fill="#4B4646" />
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC" />
</svg>
) : p.logo === "pi" ? (
<svg x="36.75" y="8" width="22" height="22" viewBox="0 0 800 800" fill="none">
<path fill="#fff" fillRule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z" />
<path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" />
</svg>
) : (
<image href={p.logo} x="36.75" y="8" width="22" height="22" filter="url(#invert-white)" />
)}
</g>
);
})}
<text x="47.5" y="46" fill="#FFFFFF" textAnchor="middle" fontSize="10" fontWeight="600" opacity={isActive ? 1 : 0.4}>
{p.label}
</text>
</g>
);
})}
{/* Active Agent Label */}
<text
x="180"
y="250"
fill={ADAPTERS[activeIndex].color}
textAnchor="middle"
fontSize="12"
fontWeight="800"
fontFamily="monospace"
letterSpacing="0.1em"
>
CONNECTED TO {ADAPTERS[activeIndex].label.toUpperCase()}
</text>
</g>
{/* Active Agent Label */}
<text
x="180"
y="250"
fill={ADAPTERS[activeIndex].color}
textAnchor="middle"
fontSize="12"
fontWeight="800"
fontFamily="monospace"
letterSpacing="0.1em"
>
CONNECTED TO {ADAPTERS[activeIndex].label.toUpperCase()}
</text>
</g>
</svg>
</div>
);
@ -153,8 +144,8 @@ function UniversalAPIDiagram() {
const CopyInstallButton = () => {
const [copied, setCopied] = useState(false);
const installCommand = 'npx skills add rivet-dev/skills -s sandbox-agent';
const shortCommand = 'npx skills add rivet-dev/skills';
const installCommand = "npx skills add rivet-dev/skills -s sandbox-agent";
const shortCommand = "npx skills add rivet-dev/skills";
const handleCopy = async () => {
try {
@ -162,7 +153,7 @@ const CopyInstallButton = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
console.error("Failed to copy:", err);
}
};
@ -211,11 +202,11 @@ export function Hero() {
updateViewportMode();
handleScroll();
window.addEventListener('resize', updateViewportMode);
window.addEventListener('scroll', handleScroll);
window.addEventListener("resize", updateViewportMode);
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener('resize', updateViewportMode);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener("resize", updateViewportMode);
window.removeEventListener("scroll", handleScroll);
};
}, []);
@ -250,7 +241,8 @@ export function Hero() {
transition={{ duration: 0.5, delay: 0.1 }}
className="mb-8 text-lg text-zinc-500 leading-relaxed"
>
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi streaming events, handling permissions, managing sessions.
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi
streaming events, handling permissions, managing sessions.
</motion.p>
<motion.div
@ -282,7 +274,6 @@ export function Hero() {
</div>
</div>
</div>
</section>
);
}

View file

@ -1,6 +1,6 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { motion } from "framer-motion";
export function Inspector() {
return (
@ -34,11 +34,7 @@ export function Inspector() {
transition={{ duration: 0.5, delay: 0.2 }}
className="overflow-hidden rounded-2xl border border-white/10"
>
<img
src="/images/inspector.png"
alt="Sandbox Agent Inspector"
className="w-full"
/>
<img src="/images/inspector.png" alt="Sandbox Agent Inspector" className="w-full" />
</motion.div>
</div>
</section>

View file

@ -1,18 +1,6 @@
'use client';
"use client";
const integrations = [
'Daytona',
'E2B',
'AI SDK',
'Anthropic',
'OpenAI',
'Docker',
'Fly.io',
'AWS Nitro',
'Postgres',
'ClickHouse',
'Rivet',
];
const integrations = ["Daytona", "E2B", "AI SDK", "Anthropic", "OpenAI", "Docker", "Fly.io", "AWS Nitro", "Postgres", "ClickHouse", "Rivet"];
export function Integrations() {
return (

View file

@ -1,15 +1,12 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { Menu, X } from 'lucide-react';
import { GitHubStars } from './GitHubStars';
import { useState, useEffect } from "react";
import { Menu, X } from "lucide-react";
import { GitHubStars } from "./GitHubStars";
function NavItem({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
className="px-3 py-2 text-sm font-medium text-zinc-400 transition-colors duration-200 hover:text-white"
>
<a href={href} className="px-3 py-2 text-sm font-medium text-zinc-400 transition-colors duration-200 hover:text-white">
{children}
</a>
);
@ -38,9 +35,7 @@ export function Navigation() {
{/* Background with blur */}
<div
className={`absolute inset-0 -z-[1] hidden overflow-hidden rounded-2xl transition-all duration-300 ease-in-out md:block ${
isScrolled
? "bg-black/80 backdrop-blur-lg"
: "bg-transparent backdrop-blur-none"
isScrolled ? "bg-black/80 backdrop-blur-lg" : "bg-transparent backdrop-blur-none"
}`}
/>
@ -76,12 +71,7 @@ export function Navigation() {
className="inline-flex items-center justify-center whitespace-nowrap rounded-md border border-white/10 px-4 py-2 h-10 text-sm hover:border-white/20 text-white/90 hover:text-white transition-colors"
aria-label="Discord"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<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.096 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.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
@ -92,10 +82,7 @@ export function Navigation() {
</div>
{/* Mobile menu button */}
<button
className="md:hidden text-zinc-400 hover:text-white p-2 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<button className="md:hidden text-zinc-400 hover:text-white p-2 transition-colors" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
@ -127,12 +114,7 @@ export function Navigation() {
onClick={() => setMobileMenuOpen(false)}
aria-label="Discord"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<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.096 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.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<span className="font-medium">Discord</span>

View file

@ -1,29 +1,28 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { Shield, Layers, Database, X, Check } from 'lucide-react';
import { motion } from "framer-motion";
import { Shield, Layers, Database, X, Check } from "lucide-react";
const frictions = [
{
icon: Shield,
title: 'Coding Agents Need Sandboxes',
title: "Coding Agents Need Sandboxes",
problem:
"You can't let AI execute arbitrary code on your production servers. Coding agents need isolated environments, but existing SDKs assume local execution.",
solution: 'A server that runs inside the sandbox and exposes HTTP/SSE.',
solution: "A server that runs inside the sandbox and exposes HTTP/SSE.",
},
{
icon: Layers,
title: 'Every Coding Agent is Different',
title: "Every Coding Agent is Different",
problem:
'Claude Code, Codex, OpenCode, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping coding agents means rewriting your entire integration.',
solution: 'One HTTP API. Write your code once, swap coding agents with a config change.',
"Claude Code, Codex, OpenCode, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping coding agents means rewriting your entire integration.",
solution: "One HTTP API. Write your code once, swap coding agents with a config change.",
},
{
icon: Database,
title: 'Sessions Are Ephemeral',
problem:
'Coding agent transcripts live in the sandbox. When the process ends, you lose everything. Debugging and replay become impossible.',
solution: 'Universal event schema streams to your storage. Persist to Postgres or Rivet, replay later, audit everything.',
title: "Sessions Are Ephemeral",
problem: "Coding agent transcripts live in the sandbox. When the process ends, you lose everything. Debugging and replay become impossible.",
solution: "Universal event schema streams to your storage. Persist to Postgres or Rivet, replay later, audit everything.",
},
];
@ -48,7 +47,8 @@ export function PainPoints() {
transition={{ duration: 0.5, delay: 0.1 }}
className="max-w-2xl text-base leading-relaxed text-zinc-500"
>
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi streaming events, handling permissions, managing sessions.
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi
streaming events, handling permissions, managing sessions.
</motion.p>
</div>
@ -70,18 +70,14 @@ export function PainPoints() {
<X className="h-3 w-3 text-zinc-600" />
<span className="text-[10px] font-medium uppercase tracking-wider text-zinc-600">Problem</span>
</div>
<p className="text-sm leading-relaxed text-zinc-500">
{friction.problem}
</p>
<p className="text-sm leading-relaxed text-zinc-500">{friction.problem}</p>
</div>
<div className="mt-auto border-t border-white/5 pt-4">
<div className="flex items-center gap-2 mb-2">
<Check className="h-3 w-3 text-green-400" />
<span className="text-[10px] font-medium uppercase tracking-wider text-zinc-400">Solution</span>
</div>
<p className="text-sm leading-relaxed text-zinc-300">
{friction.solution}
</p>
<p className="text-sm leading-relaxed text-zinc-300">{friction.solution}</p>
</div>
</div>
))}

View file

@ -1,26 +1,26 @@
'use client';
"use client";
import { Workflow, Database, Server } from 'lucide-react';
import { FeatureIcon } from './ui/FeatureIcon';
import { Workflow, Database, Server } from "lucide-react";
import { FeatureIcon } from "./ui/FeatureIcon";
const problems = [
{
title: 'Universal Agent API',
desc: 'Claude Code, Codex, OpenCode, Amp, and Pi each have different APIs. We provide a single interface to control them all.',
title: "Universal Agent API",
desc: "Claude Code, Codex, OpenCode, Amp, and Pi each have different APIs. We provide a single interface to control them all.",
icon: Workflow,
color: 'text-accent',
color: "text-accent",
},
{
title: 'Universal Transcripts',
desc: 'Every agent has its own event format. Our universal schema normalizes them all — stream, store, and replay with ease.',
title: "Universal Transcripts",
desc: "Every agent has its own event format. Our universal schema normalizes them all — stream, store, and replay with ease.",
icon: Database,
color: 'text-purple-400',
color: "text-purple-400",
},
{
title: 'Run Anywhere',
desc: 'Lightweight Rust daemon runs locally or in any environment. One command to bridge coding agents to your system.',
title: "Run Anywhere",
desc: "Lightweight Rust daemon runs locally or in any environment. One command to bridge coding agents to your system.",
icon: Server,
color: 'text-green-400',
color: "text-green-400",
},
];
@ -30,17 +30,12 @@ export function ProblemsSolved() {
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-white mb-4">Why Coding Agent SDK?</h2>
<p className="text-zinc-400 max-w-xl mx-auto">
Solving the three fundamental friction points of agentic software development.
</p>
<p className="text-zinc-400 max-w-xl mx-auto">Solving the three fundamental friction points of agentic software development.</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{problems.map((item, idx) => (
<div
key={idx}
className="group p-8 rounded-2xl bg-zinc-900/40 border border-white/5 hover:border-accent/30 transition-all duration-300"
>
<div key={idx} className="group p-8 rounded-2xl bg-zinc-900/40 border border-white/5 hover:border-accent/30 transition-all duration-300">
<FeatureIcon icon={item.icon} color={item.color} />
<h3 className="text-xl font-bold text-white mb-3">{item.title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">{item.desc}</p>

View file

@ -3,9 +3,5 @@ interface BadgeProps {
}
export function Badge({ children }: BadgeProps) {
return (
<span className="inline-flex px-3 py-1 rounded-full bg-accent/10 border border-accent/20 text-accent text-xs font-mono font-medium">
{children}
</span>
);
return <span className="inline-flex px-3 py-1 rounded-full bg-accent/10 border border-accent/20 text-accent text-xs font-mono font-medium">{children}</span>;
}

View file

@ -1,34 +1,27 @@
import type { ReactNode } from 'react';
import type { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
href?: string;
onClick?: () => void;
className?: string;
}
export function Button({
children,
variant = 'primary',
size = 'md',
href,
onClick,
className = ''
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-bold rounded-lg transition-all';
export function Button({ children, variant = "primary", size = "md", href, onClick, className = "" }: ButtonProps) {
const baseStyles = "inline-flex items-center justify-center font-bold rounded-lg transition-all";
const variants = {
primary: 'bg-white text-black hover:bg-zinc-200',
secondary: 'bg-zinc-900 border border-white/10 text-white hover:bg-zinc-800',
ghost: 'text-zinc-400 hover:text-white',
primary: "bg-white text-black hover:bg-zinc-200",
secondary: "bg-zinc-900 border border-white/10 text-white hover:bg-zinc-800",
ghost: "text-zinc-400 hover:text-white",
};
const sizes = {
sm: 'h-9 px-4 text-sm',
md: 'h-12 px-8 text-sm',
lg: 'h-14 px-10 text-base',
sm: "h-9 px-4 text-sm",
md: "h-12 px-8 text-sm",
lg: "h-14 px-10 text-base",
};
const classes = `${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`;

View file

@ -1,7 +1,7 @@
'use client';
"use client";
import { useState } from 'react';
import { Copy, CheckCircle2 } from 'lucide-react';
import { useState } from "react";
import { Copy, CheckCircle2 } from "lucide-react";
interface CopyButtonProps {
text: string;
@ -17,16 +17,8 @@ export function CopyButton({ text }: CopyButtonProps) {
};
return (
<button
onClick={handleCopy}
className="p-2 hover:bg-white/10 rounded-md transition-colors text-zinc-500 hover:text-white"
aria-label="Copy to clipboard"
>
{copied ? (
<CheckCircle2 className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
<button onClick={handleCopy} className="p-2 hover:bg-white/10 rounded-md transition-colors text-zinc-500 hover:text-white" aria-label="Copy to clipboard">
{copied ? <CheckCircle2 className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
);
}

View file

@ -1,4 +1,4 @@
import type { LucideIcon } from 'lucide-react';
import type { LucideIcon } from "lucide-react";
interface FeatureIconProps {
icon: LucideIcon;
@ -8,12 +8,12 @@ interface FeatureIconProps {
glowShadow?: string;
}
export function FeatureIcon({
icon: Icon,
color = 'text-accent',
bgColor = 'bg-accent/10',
hoverBgColor = 'group-hover:bg-accent/20',
glowShadow = 'group-hover:shadow-[0_0_15px_rgba(59,130,246,0.5)]'
export function FeatureIcon({
icon: Icon,
color = "text-accent",
bgColor = "bg-accent/10",
hoverBgColor = "group-hover:bg-accent/20",
glowShadow = "group-hover:shadow-[0_0_15px_rgba(59,130,246,0.5)]",
}: FeatureIconProps) {
return (
<div className={`rounded ${bgColor} p-2 ${color} transition-all duration-500 ${hoverBgColor} ${glowShadow}`}>

View file

@ -4,29 +4,30 @@ interface Props {
description?: string;
}
const { title, description = "Universal SDK for coding agents. Control Claude Code, Codex, OpenCode, Amp, and Pi with unified events and sessions." } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, 'https://sandbox-agent.dev');
const ogImageURL = new URL('/og.png', 'https://sandbox-agent.dev');
const { title, description = "Universal SDK for coding agents. Control Claude Code, Codex, OpenCode, Amp, and Pi with unified events and sessions." } =
Astro.props;
const canonicalURL = new URL(Astro.url.pathname, "https://sandbox-agent.dev");
const ogImageURL = new URL("/og.png", "https://sandbox-agent.dev");
const structuredData = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Sandbox Agent SDK",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Linux, macOS, Windows",
"description": description,
"url": "https://sandbox-agent.dev",
"author": {
name: "Sandbox Agent SDK",
applicationCategory: "DeveloperApplication",
operatingSystem: "Linux, macOS, Windows",
description: description,
url: "https://sandbox-agent.dev",
author: {
"@type": "Organization",
"name": "Rivet",
"url": "https://rivet.dev"
name: "Rivet",
url: "https://rivet.dev",
},
"offers": {
offers: {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
price: "0",
priceCurrency: "USD",
},
"keywords": "coding agents, AI SDK, Claude Code, Codex, OpenCode, Amp, sandbox, remote code execution, developer tools"
keywords: "coding agents, AI SDK, Claude Code, Codex, OpenCode, Amp, sandbox, remote code execution, developer tools",
};
---

View file

@ -1,13 +1,13 @@
---
import Layout from '../layouts/Layout.astro';
import { Navigation } from '../components/Navigation';
import { Hero } from '../components/Hero';
import { PainPoints } from '../components/PainPoints';
import { FeatureGrid } from '../components/FeatureGrid';
import { GetStarted } from '../components/GetStarted';
import { Inspector } from '../components/Inspector';
import { FAQ } from '../components/FAQ';
import { Footer } from '../components/Footer';
import Layout from "../layouts/Layout.astro";
import { Navigation } from "../components/Navigation";
import { Hero } from "../components/Hero";
import { PainPoints } from "../components/PainPoints";
import { FeatureGrid } from "../components/FeatureGrid";
import { GetStarted } from "../components/GetStarted";
import { Inspector } from "../components/Inspector";
import { FAQ } from "../components/FAQ";
import { Footer } from "../components/Footer";
---
<Layout title="Sandbox Agent SDK - Run Coding Agents in Sandboxes. Control Them Over HTTP.">

View file

@ -1,62 +1,62 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
colors: {
// Primary accent (OrangeRed)
accent: '#FF4500',
accent: "#FF4500",
// Extended color palette
background: '#000000',
'text-primary': '#FAFAFA',
'text-secondary': '#A0A0A0',
border: '#252525',
background: "#000000",
"text-primary": "#FAFAFA",
"text-secondary": "#A0A0A0",
border: "#252525",
// Code syntax highlighting
'code-keyword': '#c084fc',
'code-function': '#60a5fa',
'code-string': '#4ade80',
'code-comment': '#737373',
"code-keyword": "#c084fc",
"code-function": "#60a5fa",
"code-string": "#4ade80",
"code-comment": "#737373",
},
fontFamily: {
sans: ['Manrope', 'system-ui', 'sans-serif'],
heading: ['Manrope', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
sans: ["Manrope", "system-ui", "sans-serif"],
heading: ["Manrope", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
'fade-in-up': 'fade-in-up 0.8s ease-out forwards',
'hero-line': 'hero-line 1s cubic-bezier(0.19, 1, 0.22, 1) forwards',
'hero-p': 'hero-p 0.8s ease-out 0.6s forwards',
'hero-cta': 'hero-p 0.8s ease-out 0.8s forwards',
'hero-visual': 'hero-p 0.8s ease-out 1s forwards',
'infinite-scroll': 'infinite-scroll 25s linear infinite',
'pulse-slow': 'pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
"fade-in-up": "fade-in-up 0.8s ease-out forwards",
"hero-line": "hero-line 1s cubic-bezier(0.19, 1, 0.22, 1) forwards",
"hero-p": "hero-p 0.8s ease-out 0.6s forwards",
"hero-cta": "hero-p 0.8s ease-out 0.8s forwards",
"hero-visual": "hero-p 0.8s ease-out 1s forwards",
"infinite-scroll": "infinite-scroll 25s linear infinite",
"pulse-slow": "pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
keyframes: {
'fade-in-up': {
from: { opacity: '0', transform: 'translateY(24px)' },
to: { opacity: '1', transform: 'translateY(0)' },
"fade-in-up": {
from: { opacity: "0", transform: "translateY(24px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
'hero-line': {
'0%': { opacity: '0', transform: 'translateY(100%) skewY(6deg)' },
'100%': { opacity: '1', transform: 'translateY(0) skewY(0deg)' },
"hero-line": {
"0%": { opacity: "0", transform: "translateY(100%) skewY(6deg)" },
"100%": { opacity: "1", transform: "translateY(0) skewY(0deg)" },
},
'hero-p': {
from: { opacity: '0', transform: 'translateY(20px)' },
to: { opacity: '1', transform: 'translateY(0)' },
"hero-p": {
from: { opacity: "0", transform: "translateY(20px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
'infinite-scroll': {
from: { transform: 'translateX(0)' },
to: { transform: 'translateX(-50%)' },
"infinite-scroll": {
from: { transform: "translateX(0)" },
to: { transform: "translateX(-50%)" },
},
'pulse-slow': {
'50%': { opacity: '.5' },
"pulse-slow": {
"50%": { opacity: ".5" },
},
},
spacing: {
header: 'var(--header-height, 3.5rem)',
header: "var(--header-height, 3.5rem)",
},
borderRadius: {
'4xl': '2rem',
"4xl": "2rem",
},
},
},

View file

@ -2,6 +2,6 @@ import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 3000
}
port: 3000,
},
});