mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 17:01:02 +00:00
908 lines
27 KiB
TypeScript
908 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
SandboxAgentError,
|
|
SandboxAgent,
|
|
type AgentInfo,
|
|
type AgentModeInfo,
|
|
type PermissionEventData,
|
|
type QuestionEventData,
|
|
type SessionInfo,
|
|
type UniversalEvent,
|
|
type UniversalItem
|
|
} from "sandbox-agent";
|
|
import ChatPanel from "./components/chat/ChatPanel";
|
|
import type { TimelineEntry } from "./components/chat/types";
|
|
import ConnectScreen from "./components/ConnectScreen";
|
|
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
|
|
import SessionSidebar from "./components/SessionSidebar";
|
|
import type { RequestLog } from "./types/requestLog";
|
|
import { buildCurl } from "./utils/http";
|
|
|
|
const defaultAgents = ["claude", "codex", "opencode", "amp", "mock"];
|
|
|
|
type ItemEventData = {
|
|
item: UniversalItem;
|
|
};
|
|
|
|
type ItemDeltaEventData = {
|
|
item_id: string;
|
|
native_item_id?: string | null;
|
|
delta: string;
|
|
};
|
|
|
|
const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
|
|
return {
|
|
item_id: itemId,
|
|
native_item_id: nativeItemId ?? null,
|
|
parent_id: null,
|
|
kind: "message",
|
|
role: null,
|
|
content: [],
|
|
status: "in_progress"
|
|
} as UniversalItem;
|
|
};
|
|
|
|
const getDefaultEndpoint = () => {
|
|
if (typeof window === "undefined") return "http://127.0.0.1:2468";
|
|
const { origin, protocol } = window.location;
|
|
if (!origin || origin === "null" || protocol === "file:") {
|
|
return "http://127.0.0.1:2468";
|
|
}
|
|
return origin;
|
|
};
|
|
|
|
const getInitialConnection = () => {
|
|
if (typeof window === "undefined") {
|
|
return { endpoint: "http://127.0.0.1:2468", token: "" };
|
|
}
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlParam = params.get("url")?.trim();
|
|
const tokenParam = params.get("token") ?? "";
|
|
return {
|
|
endpoint: urlParam && urlParam.length > 0 ? urlParam : getDefaultEndpoint(),
|
|
token: tokenParam
|
|
};
|
|
};
|
|
|
|
export default function App() {
|
|
const issueTrackerUrl = "https://github.com/rivet-dev/sandbox-agent/issues/new";
|
|
const initialConnectionRef = useRef(getInitialConnection());
|
|
const [endpoint, setEndpoint] = useState(initialConnectionRef.current.endpoint);
|
|
const [token, setToken] = useState(initialConnectionRef.current.token);
|
|
const [connected, setConnected] = useState(false);
|
|
const [connecting, setConnecting] = useState(false);
|
|
const [connectError, setConnectError] = useState<string | null>(null);
|
|
|
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentModeInfo[]>>({});
|
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
|
const [agentsError, setAgentsError] = useState<string | null>(null);
|
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
|
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
|
const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({});
|
|
const [modesErrorByAgent, setModesErrorByAgent] = useState<Record<string, string | null>>({});
|
|
|
|
const [agentId, setAgentId] = useState("claude");
|
|
const [agentMode, setAgentMode] = useState("");
|
|
const [permissionMode, setPermissionMode] = useState("default");
|
|
const [model, setModel] = useState("");
|
|
const [variant, setVariant] = useState("");
|
|
const [sessionId, setSessionId] = useState("");
|
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
|
|
|
const [message, setMessage] = useState("");
|
|
const [events, setEvents] = useState<UniversalEvent[]>([]);
|
|
const [offset, setOffset] = useState(0);
|
|
const offsetRef = useRef(0);
|
|
const [eventsLoading, setEventsLoading] = useState(false);
|
|
|
|
const [polling, setPolling] = useState(false);
|
|
const pollTimerRef = useRef<number | null>(null);
|
|
const [turnStreaming, setTurnStreaming] = useState(false);
|
|
const [streamMode, setStreamMode] = useState<"poll" | "sse" | "turn">("sse");
|
|
const [eventError, setEventError] = useState<string | null>(null);
|
|
|
|
const [questionSelections, setQuestionSelections] = useState<Record<string, string[][]>>({});
|
|
const [questionStatus, setQuestionStatus] = useState<Record<string, "replied" | "rejected">>({});
|
|
const [permissionStatus, setPermissionStatus] = useState<Record<string, "replied" | "rejected">>({});
|
|
|
|
const [requestLog, setRequestLog] = useState<RequestLog[]>([]);
|
|
const logIdRef = useRef(1);
|
|
const [copiedLogId, setCopiedLogId] = useState<number | null>(null);
|
|
|
|
const [debugTab, setDebugTab] = useState<DebugTab>("events");
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const clientRef = useRef<SandboxAgent | null>(null);
|
|
const sseAbortRef = useRef<AbortController | null>(null);
|
|
const turnAbortRef = useRef<AbortController | null>(null);
|
|
|
|
const logRequest = useCallback((entry: RequestLog) => {
|
|
setRequestLog((prev) => {
|
|
const next = [entry, ...prev];
|
|
return next.slice(0, 200);
|
|
});
|
|
}, []);
|
|
|
|
const createClient = useCallback(async () => {
|
|
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 entry: RequestLog = {
|
|
id: logId,
|
|
method,
|
|
url,
|
|
body: bodyText,
|
|
time: new Date().toLocaleTimeString(),
|
|
curl
|
|
};
|
|
let logged = false;
|
|
|
|
try {
|
|
const response = await fetch(input, init);
|
|
logRequest({ ...entry, status: response.status });
|
|
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 client = await SandboxAgent.connect({
|
|
baseUrl: endpoint,
|
|
token: token || undefined,
|
|
fetch: fetchWithLog
|
|
});
|
|
clientRef.current = client;
|
|
return client;
|
|
}, [endpoint, token, logRequest]);
|
|
|
|
const getClient = useCallback((): SandboxAgent => {
|
|
if (!clientRef.current) {
|
|
throw new Error("Not connected");
|
|
}
|
|
return clientRef.current;
|
|
}, []);
|
|
|
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
|
if (error instanceof SandboxAgentError) {
|
|
return error.problem?.detail ?? error.problem?.title ?? error.message;
|
|
}
|
|
return error instanceof Error ? error.message : fallback;
|
|
};
|
|
|
|
const connectToDaemon = async (reportError: boolean) => {
|
|
setConnecting(true);
|
|
if (reportError) {
|
|
setConnectError(null);
|
|
}
|
|
try {
|
|
const client = await createClient();
|
|
await client.getHealth();
|
|
setConnected(true);
|
|
await refreshAgents();
|
|
await fetchSessions();
|
|
if (reportError) {
|
|
setConnectError(null);
|
|
}
|
|
} catch (error) {
|
|
if (reportError) {
|
|
const messageText = getErrorMessage(error, "Unable to connect");
|
|
setConnectError(messageText);
|
|
}
|
|
setConnected(false);
|
|
clientRef.current = null;
|
|
} finally {
|
|
setConnecting(false);
|
|
}
|
|
};
|
|
|
|
const connect = () => connectToDaemon(true);
|
|
|
|
const disconnect = () => {
|
|
setConnected(false);
|
|
clientRef.current = null;
|
|
setSessionError(null);
|
|
setEvents([]);
|
|
setOffset(0);
|
|
offsetRef.current = 0;
|
|
setEventError(null);
|
|
stopPolling();
|
|
stopSse();
|
|
stopTurnStream();
|
|
setAgents([]);
|
|
setSessions([]);
|
|
setAgentsLoading(false);
|
|
setSessionsLoading(false);
|
|
setAgentsError(null);
|
|
setSessionsError(null);
|
|
};
|
|
|
|
const refreshAgents = async () => {
|
|
setAgentsLoading(true);
|
|
setAgentsError(null);
|
|
try {
|
|
const data = await getClient().listAgents();
|
|
const agentList = data.agents ?? [];
|
|
setAgents(agentList);
|
|
for (const agent of agentList) {
|
|
if (agent.installed) {
|
|
loadModes(agent.id);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setAgentsError(getErrorMessage(error, "Unable to refresh agents"));
|
|
} finally {
|
|
setAgentsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchSessions = async () => {
|
|
setSessionsLoading(true);
|
|
setSessionsError(null);
|
|
try {
|
|
const data = await getClient().listSessions();
|
|
const sessionList = data.sessions ?? [];
|
|
setSessions(sessionList);
|
|
} catch {
|
|
setSessionsError("Unable to load sessions.");
|
|
} finally {
|
|
setSessionsLoading(false);
|
|
}
|
|
};
|
|
|
|
const installAgent = async (targetId: string, reinstall: boolean) => {
|
|
try {
|
|
await getClient().installAgent(targetId, { reinstall });
|
|
await refreshAgents();
|
|
} catch (error) {
|
|
setConnectError(getErrorMessage(error, "Install failed"));
|
|
}
|
|
};
|
|
|
|
const loadModes = async (targetId: string) => {
|
|
setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: true }));
|
|
setModesErrorByAgent((prev) => ({ ...prev, [targetId]: null }));
|
|
try {
|
|
const data = await getClient().getAgentModes(targetId);
|
|
const modes = data.modes ?? [];
|
|
setModesByAgent((prev) => ({ ...prev, [targetId]: modes }));
|
|
} catch {
|
|
setModesErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load modes." }));
|
|
} finally {
|
|
setModesLoadingByAgent((prev) => ({ ...prev, [targetId]: false }));
|
|
}
|
|
};
|
|
|
|
const sendMessage = async () => {
|
|
const prompt = message.trim();
|
|
if (!prompt || !sessionId || turnStreaming) return;
|
|
setSessionError(null);
|
|
setMessage("");
|
|
|
|
if (streamMode === "turn") {
|
|
await startTurnStream(prompt);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await getClient().postMessage(sessionId, { message: prompt });
|
|
if (!polling) {
|
|
if (streamMode === "poll") {
|
|
startPolling();
|
|
} else {
|
|
startSse();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setSessionError(getErrorMessage(error, "Unable to send message"));
|
|
}
|
|
};
|
|
|
|
const selectSession = (session: SessionInfo) => {
|
|
stopPolling();
|
|
stopSse();
|
|
stopTurnStream();
|
|
setSessionId(session.sessionId);
|
|
setAgentId(session.agent);
|
|
setAgentMode(session.agentMode);
|
|
setPermissionMode(session.permissionMode);
|
|
setModel(session.model ?? "");
|
|
setVariant(session.variant ?? "");
|
|
setEvents([]);
|
|
setOffset(0);
|
|
offsetRef.current = 0;
|
|
setSessionError(null);
|
|
};
|
|
|
|
const createNewSession = async (nextAgentId?: string) => {
|
|
stopPolling();
|
|
stopSse();
|
|
stopTurnStream();
|
|
const selectedAgent = nextAgentId ?? agentId;
|
|
if (nextAgentId) {
|
|
setAgentId(nextAgentId);
|
|
}
|
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
let id = "session-";
|
|
for (let i = 0; i < 8; i++) {
|
|
id += chars[Math.floor(Math.random() * chars.length)];
|
|
}
|
|
setSessionId(id);
|
|
setEvents([]);
|
|
setOffset(0);
|
|
offsetRef.current = 0;
|
|
setSessionError(null);
|
|
|
|
try {
|
|
const body: {
|
|
agent: string;
|
|
agentMode?: string;
|
|
permissionMode?: string;
|
|
model?: string;
|
|
variant?: string;
|
|
} = { agent: selectedAgent };
|
|
if (agentMode) body.agentMode = agentMode;
|
|
if (permissionMode) body.permissionMode = permissionMode;
|
|
if (model) body.model = model;
|
|
if (variant) body.variant = variant;
|
|
|
|
await getClient().createSession(id, body);
|
|
await fetchSessions();
|
|
} catch (error) {
|
|
setSessionError(getErrorMessage(error, "Unable to create session"));
|
|
}
|
|
};
|
|
|
|
const appendEvents = useCallback((incoming: UniversalEvent[]) => {
|
|
if (!incoming.length) return;
|
|
setEvents((prev) => [...prev, ...incoming]);
|
|
const lastSeq = incoming[incoming.length - 1]?.sequence ?? offsetRef.current;
|
|
offsetRef.current = lastSeq;
|
|
setOffset(lastSeq);
|
|
}, []);
|
|
|
|
const fetchEvents = useCallback(async () => {
|
|
if (!sessionId) return;
|
|
setEventsLoading(true);
|
|
try {
|
|
const response = await getClient().getEvents(sessionId, {
|
|
offset: offsetRef.current,
|
|
limit: 200
|
|
});
|
|
const newEvents = response.events ?? [];
|
|
appendEvents(newEvents);
|
|
setEventError(null);
|
|
} catch (error) {
|
|
setEventError(getErrorMessage(error, "Unable to fetch events"));
|
|
} finally {
|
|
setEventsLoading(false);
|
|
}
|
|
}, [appendEvents, getClient, sessionId]);
|
|
|
|
const startPolling = () => {
|
|
stopSse();
|
|
if (pollTimerRef.current) return;
|
|
setPolling(true);
|
|
fetchEvents();
|
|
pollTimerRef.current = window.setInterval(fetchEvents, 500);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (pollTimerRef.current) {
|
|
window.clearInterval(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
setPolling(false);
|
|
};
|
|
|
|
const startSse = () => {
|
|
stopPolling();
|
|
if (sseAbortRef.current) return;
|
|
if (!sessionId) {
|
|
setEventError("Select or create a session first.");
|
|
return;
|
|
}
|
|
setEventError(null);
|
|
setPolling(true);
|
|
const controller = new AbortController();
|
|
sseAbortRef.current = controller;
|
|
const start = async () => {
|
|
try {
|
|
for await (const event of getClient().streamEvents(
|
|
sessionId,
|
|
{ offset: offsetRef.current },
|
|
controller.signal
|
|
)) {
|
|
appendEvents([event]);
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
setEventError(getErrorMessage(error, "SSE connection error. Falling back to polling."));
|
|
stopSse();
|
|
startPolling();
|
|
} finally {
|
|
if (sseAbortRef.current === controller) {
|
|
sseAbortRef.current = null;
|
|
setPolling(false);
|
|
}
|
|
}
|
|
};
|
|
void start();
|
|
};
|
|
|
|
const stopSse = () => {
|
|
if (sseAbortRef.current) {
|
|
sseAbortRef.current.abort();
|
|
sseAbortRef.current = null;
|
|
}
|
|
setPolling(false);
|
|
};
|
|
|
|
const startTurnStream = async (prompt: string) => {
|
|
stopPolling();
|
|
stopSse();
|
|
if (turnAbortRef.current) return;
|
|
if (!sessionId) {
|
|
setEventError("Select or create a session first.");
|
|
return;
|
|
}
|
|
setEventError(null);
|
|
setTurnStreaming(true);
|
|
const controller = new AbortController();
|
|
turnAbortRef.current = controller;
|
|
try {
|
|
for await (const event of getClient().streamTurn(
|
|
sessionId,
|
|
{ message: prompt },
|
|
undefined,
|
|
controller.signal
|
|
)) {
|
|
appendEvents([event]);
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
setEventError(getErrorMessage(error, "Turn stream error."));
|
|
} finally {
|
|
if (turnAbortRef.current === controller) {
|
|
turnAbortRef.current = null;
|
|
setTurnStreaming(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const stopTurnStream = () => {
|
|
if (turnAbortRef.current) {
|
|
turnAbortRef.current.abort();
|
|
turnAbortRef.current = null;
|
|
}
|
|
setTurnStreaming(false);
|
|
};
|
|
|
|
const resetEvents = () => {
|
|
setEvents([]);
|
|
setOffset(0);
|
|
offsetRef.current = 0;
|
|
};
|
|
|
|
const handleCopy = async (entry: RequestLog) => {
|
|
try {
|
|
await navigator.clipboard.writeText(entry.curl);
|
|
setCopiedLogId(entry.id);
|
|
window.setTimeout(() => setCopiedLogId(null), 1500);
|
|
} catch {
|
|
setCopiedLogId(null);
|
|
}
|
|
};
|
|
|
|
const selectQuestionOption = (requestId: string, optionLabel: string) => {
|
|
setQuestionSelections((prev) => ({
|
|
...prev,
|
|
[requestId]: [[optionLabel]]
|
|
}));
|
|
};
|
|
|
|
const answerQuestion = async (request: QuestionEventData) => {
|
|
const answers = questionSelections[request.question_id] ?? [];
|
|
try {
|
|
await getClient().replyQuestion(sessionId, request.question_id, { answers });
|
|
setQuestionStatus((prev) => ({ ...prev, [request.question_id]: "replied" }));
|
|
} catch (error) {
|
|
setEventError(getErrorMessage(error, "Unable to reply"));
|
|
}
|
|
};
|
|
|
|
const rejectQuestion = async (requestId: string) => {
|
|
try {
|
|
await getClient().rejectQuestion(sessionId, requestId);
|
|
setQuestionStatus((prev) => ({ ...prev, [requestId]: "rejected" }));
|
|
} catch (error) {
|
|
setEventError(getErrorMessage(error, "Unable to reject"));
|
|
}
|
|
};
|
|
|
|
const replyPermission = async (requestId: string, reply: "once" | "always" | "reject") => {
|
|
try {
|
|
await getClient().replyPermission(sessionId, requestId, { reply });
|
|
setPermissionStatus((prev) => ({ ...prev, [requestId]: "replied" }));
|
|
} catch (error) {
|
|
setEventError(getErrorMessage(error, "Unable to reply"));
|
|
}
|
|
};
|
|
|
|
const endSession = async () => {
|
|
if (!sessionId) return;
|
|
try {
|
|
await getClient().terminateSession(sessionId);
|
|
await fetchSessions();
|
|
} catch (error) {
|
|
setSessionError(getErrorMessage(error, "Unable to end session"));
|
|
}
|
|
};
|
|
|
|
const questionRequests = useMemo(() => {
|
|
const latestById = new Map<string, QuestionEventData>();
|
|
for (const event of events) {
|
|
if (event.type === "question.requested" || event.type === "question.resolved") {
|
|
const data = event.data as QuestionEventData;
|
|
latestById.set(data.question_id, data);
|
|
}
|
|
}
|
|
return Array.from(latestById.values()).filter(
|
|
(request) => request.status === "requested" && !questionStatus[request.question_id]
|
|
);
|
|
}, [events, questionStatus]);
|
|
|
|
const permissionRequests = useMemo(() => {
|
|
const latestById = new Map<string, PermissionEventData>();
|
|
for (const event of events) {
|
|
if (event.type === "permission.requested" || event.type === "permission.resolved") {
|
|
const data = event.data as PermissionEventData;
|
|
latestById.set(data.permission_id, data);
|
|
}
|
|
}
|
|
return Array.from(latestById.values()).filter(
|
|
(request) => request.status === "requested" && !permissionStatus[request.permission_id]
|
|
);
|
|
}, [events, permissionStatus]);
|
|
|
|
const transcriptEntries = useMemo(() => {
|
|
const entries: TimelineEntry[] = [];
|
|
const itemMap = new Map<string, TimelineEntry>();
|
|
|
|
const upsertItemEntry = (item: UniversalItem, time: string) => {
|
|
let entry = itemMap.get(item.item_id);
|
|
if (!entry) {
|
|
entry = {
|
|
id: item.item_id,
|
|
kind: "item",
|
|
time,
|
|
item,
|
|
deltaText: ""
|
|
};
|
|
itemMap.set(item.item_id, entry);
|
|
entries.push(entry);
|
|
} else {
|
|
entry.item = item;
|
|
entry.time = time;
|
|
}
|
|
return entry;
|
|
};
|
|
|
|
for (const event of events) {
|
|
switch (event.type) {
|
|
case "item.started": {
|
|
const data = event.data as ItemEventData;
|
|
upsertItemEntry(data.item, event.time);
|
|
break;
|
|
}
|
|
case "item.delta": {
|
|
const data = event.data as ItemDeltaEventData;
|
|
const stub = buildStubItem(data.item_id, data.native_item_id);
|
|
const entry = upsertItemEntry(stub, event.time);
|
|
entry.deltaText = `${entry.deltaText ?? ""}${data.delta ?? ""}`;
|
|
break;
|
|
}
|
|
case "item.completed": {
|
|
const data = event.data as ItemEventData;
|
|
const entry = upsertItemEntry(data.item, event.time);
|
|
entry.deltaText = "";
|
|
break;
|
|
}
|
|
case "error": {
|
|
const data = event.data as { message: string; code?: string | null };
|
|
entries.push({
|
|
id: event.event_id,
|
|
kind: "meta",
|
|
time: event.time,
|
|
meta: {
|
|
title: data.code ? `Error - ${data.code}` : "Error",
|
|
detail: data.message,
|
|
severity: "error"
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case "agent.unparsed": {
|
|
const data = event.data as { error: string; location: string };
|
|
entries.push({
|
|
id: event.event_id,
|
|
kind: "meta",
|
|
time: event.time,
|
|
meta: {
|
|
title: "Agent parse failure",
|
|
detail: `${data.location}: ${data.error}`,
|
|
severity: "error"
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case "session.started": {
|
|
entries.push({
|
|
id: event.event_id,
|
|
kind: "meta",
|
|
time: event.time,
|
|
meta: {
|
|
title: "Session started",
|
|
severity: "info"
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case "session.ended": {
|
|
const data = event.data as { reason: string; terminated_by: string };
|
|
entries.push({
|
|
id: event.event_id,
|
|
kind: "meta",
|
|
time: event.time,
|
|
meta: {
|
|
title: "Session ended",
|
|
detail: `${data.reason} - ${data.terminated_by}`,
|
|
severity: "info"
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}, [events]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopPolling();
|
|
stopSse();
|
|
stopTurnStream();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const attempt = async () => {
|
|
await connectToDaemon(false);
|
|
};
|
|
attempt().catch(() => {
|
|
if (!active) return;
|
|
setConnecting(false);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!connected) return;
|
|
refreshAgents();
|
|
}, [connected]);
|
|
|
|
useEffect(() => {
|
|
if (!connected || !sessionId || polling) return;
|
|
if (streamMode === "turn") return;
|
|
const hasSession = sessions.some((session) => session.sessionId === sessionId);
|
|
if (!hasSession) return;
|
|
if (streamMode === "poll") {
|
|
startPolling();
|
|
} else {
|
|
startSse();
|
|
}
|
|
}, [connected, sessionId, polling, streamMode, sessions]);
|
|
|
|
useEffect(() => {
|
|
if (streamMode === "turn") {
|
|
stopPolling();
|
|
stopSse();
|
|
} else if (turnStreaming) {
|
|
stopTurnStream();
|
|
}
|
|
}, [streamMode, turnStreaming]);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [transcriptEntries]);
|
|
|
|
useEffect(() => {
|
|
if (connected && agentId && !modesByAgent[agentId]) {
|
|
loadModes(agentId);
|
|
}
|
|
}, [connected, agentId]);
|
|
|
|
useEffect(() => {
|
|
const modes = modesByAgent[agentId];
|
|
if (modes && modes.length > 0 && !agentMode) {
|
|
setAgentMode(modes[0].id);
|
|
}
|
|
}, [modesByAgent, agentId]);
|
|
|
|
const currentAgent = agents.find((agent) => agent.id === agentId);
|
|
const activeModes = modesByAgent[agentId] ?? [];
|
|
const modesLoading = modesLoadingByAgent[agentId] ?? false;
|
|
const modesError = modesErrorByAgent[agentId] ?? null;
|
|
const agentDisplayNames: Record<string, string> = {
|
|
claude: "Claude Code",
|
|
codex: "Codex",
|
|
opencode: "OpenCode",
|
|
amp: "Amp",
|
|
mock: "Mock"
|
|
};
|
|
const agentLabel = agentDisplayNames[agentId] ?? agentId;
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
const toggleStream = () => {
|
|
if (streamMode === "turn") {
|
|
return;
|
|
}
|
|
if (polling) {
|
|
if (streamMode === "poll") {
|
|
stopPolling();
|
|
} else {
|
|
stopSse();
|
|
}
|
|
} else if (streamMode === "poll") {
|
|
startPolling();
|
|
} else {
|
|
startSse();
|
|
}
|
|
};
|
|
|
|
if (!connected) {
|
|
return (
|
|
<ConnectScreen
|
|
endpoint={endpoint}
|
|
token={token}
|
|
connectError={connectError}
|
|
connecting={connecting}
|
|
onEndpointChange={setEndpoint}
|
|
onTokenChange={setToken}
|
|
onConnect={connect}
|
|
reportUrl={issueTrackerUrl}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="header">
|
|
<div className="header-left">
|
|
<div className="logo">SA</div>
|
|
<span className="header-title">Sandbox Agent</span>
|
|
</div>
|
|
<div className="header-right">
|
|
<a className="button ghost small" href={issueTrackerUrl} target="_blank" rel="noreferrer">
|
|
Report Bug
|
|
</a>
|
|
<span className="header-endpoint">{endpoint}</span>
|
|
<button className="button secondary small" onClick={disconnect}>
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="main-layout">
|
|
<SessionSidebar
|
|
sessions={sessions}
|
|
selectedSessionId={sessionId}
|
|
onSelectSession={selectSession}
|
|
onRefresh={fetchSessions}
|
|
onCreateSession={createNewSession}
|
|
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
|
|
agentsLoading={agentsLoading}
|
|
agentsError={agentsError}
|
|
sessionsLoading={sessionsLoading}
|
|
sessionsError={sessionsError}
|
|
/>
|
|
|
|
<ChatPanel
|
|
sessionId={sessionId}
|
|
polling={polling}
|
|
turnStreaming={turnStreaming}
|
|
transcriptEntries={transcriptEntries}
|
|
sessionError={sessionError}
|
|
message={message}
|
|
onMessageChange={setMessage}
|
|
onSendMessage={sendMessage}
|
|
onKeyDown={handleKeyDown}
|
|
onCreateSession={createNewSession}
|
|
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
|
|
agentsLoading={agentsLoading}
|
|
agentsError={agentsError}
|
|
messagesEndRef={messagesEndRef}
|
|
agentLabel={agentLabel}
|
|
agentMode={agentMode}
|
|
permissionMode={permissionMode}
|
|
model={model}
|
|
variant={variant}
|
|
streamMode={streamMode}
|
|
activeModes={activeModes}
|
|
currentAgentVersion={currentAgent?.version ?? null}
|
|
modesLoading={modesLoading}
|
|
modesError={modesError}
|
|
onAgentModeChange={setAgentMode}
|
|
onPermissionModeChange={setPermissionMode}
|
|
onModelChange={setModel}
|
|
onVariantChange={setVariant}
|
|
onStreamModeChange={setStreamMode}
|
|
onToggleStream={toggleStream}
|
|
onEndSession={endSession}
|
|
hasSession={Boolean(sessionId)}
|
|
eventError={eventError}
|
|
questionRequests={questionRequests}
|
|
permissionRequests={permissionRequests}
|
|
questionSelections={questionSelections}
|
|
onSelectQuestionOption={selectQuestionOption}
|
|
onAnswerQuestion={answerQuestion}
|
|
onRejectQuestion={rejectQuestion}
|
|
onReplyPermission={replyPermission}
|
|
/>
|
|
|
|
<DebugPanel
|
|
debugTab={debugTab}
|
|
onDebugTabChange={setDebugTab}
|
|
events={events}
|
|
offset={offset}
|
|
onFetchEvents={fetchEvents}
|
|
onResetEvents={resetEvents}
|
|
eventsLoading={eventsLoading}
|
|
eventsError={eventError}
|
|
requestLog={requestLog}
|
|
copiedLogId={copiedLogId}
|
|
onClearRequestLog={() => setRequestLog([])}
|
|
onCopyRequestLog={handleCopy}
|
|
agents={agents}
|
|
defaultAgents={defaultAgents}
|
|
modesByAgent={modesByAgent}
|
|
onRefreshAgents={refreshAgents}
|
|
onInstallAgent={installAgent}
|
|
agentsLoading={agentsLoading}
|
|
agentsError={agentsError}
|
|
/>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|