mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 08:03:46 +00:00
* chore: recover hamburg workspace state * chore: drop workspace context files * refactor: generalize permissions example * refactor: parse permissions example flags * docs: clarify why fs and terminal stay native * feat: add interactive permission prompt UI to Inspector Add permission request handling to the Inspector UI so users can Allow, Always Allow, or Reject tool calls that require permissions instead of having them auto-cancelled. Wires up SDK onPermissionRequest/respondPermission through App → ChatPanel → ChatMessages with proper toolCallId-to-pendingId mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent permission reply from silently escalating "once" to "always" Remove allow_always from the fallback chain when the user replies "once", aligning with the ACP spec which says "map by option kind first" with no fallback for allow_once. Also fix Inspector to use rawSend, revert hydration guard to accept empty configOptions, and handle respondPermission errors by rejecting the pending promise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
272 lines
7.8 KiB
TypeScript
272 lines
7.8 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { SandboxAgent } from "sandbox-agent";
|
|
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
|
|
|
|
export function App() {
|
|
const [sandboxName, setSandboxName] = useState("demo");
|
|
const [prompt, setPrompt] = useState("");
|
|
const [output, setOutput] = useState("");
|
|
const [status, setStatus] = useState<"idle" | "connecting" | "ready" | "thinking">("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const clientRef = useRef<SandboxAgent | null>(null);
|
|
const sessionIdRef = useRef<string>(`session-${Date.now()}`);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const isThinkingRef = useRef(false);
|
|
|
|
const log = useCallback((msg: string) => {
|
|
setOutput((prev) => prev + msg + "\n");
|
|
}, []);
|
|
|
|
const connect = useCallback(async () => {
|
|
setStatus("connecting");
|
|
setError(null);
|
|
setOutput("");
|
|
|
|
try {
|
|
// Connect via proxy endpoint (need full URL for SDK)
|
|
const baseUrl = `${window.location.origin}/sandbox/${encodeURIComponent(sandboxName)}/proxy`;
|
|
log(`Connecting to sandbox: ${sandboxName}`);
|
|
|
|
const client = await SandboxAgent.connect({ baseUrl });
|
|
clientRef.current = client;
|
|
|
|
// Wait for health (this also ensures the container is started)
|
|
log("Waiting for sandbox-agent to be ready...");
|
|
for (let i = 0; i < 30; i++) {
|
|
try {
|
|
await client.getHealth();
|
|
break;
|
|
} catch {
|
|
if (i === 29) throw new Error("Timeout waiting for sandbox-agent");
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
}
|
|
|
|
// Create session
|
|
await client.createSession(sessionIdRef.current, { agent: "claude" });
|
|
log("Session created. Ready to chat.\n");
|
|
|
|
setStatus("ready");
|
|
|
|
// Start listening for events
|
|
startEventStream(client);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setStatus("idle");
|
|
}
|
|
}, [sandboxName, log]);
|
|
|
|
const startEventStream = useCallback(
|
|
async (client: SandboxAgent) => {
|
|
abortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
try {
|
|
for await (const event of client.streamEvents(sessionIdRef.current, undefined, controller.signal)) {
|
|
console.log("Event:", event.type, event.data);
|
|
|
|
// Auto-approve permissions
|
|
if (event.type === "permission.requested") {
|
|
const data = event.data as PermissionEventData;
|
|
log(`[Auto-approved] ${data.action}`);
|
|
await client.respondPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
|
}
|
|
|
|
// Reject questions (don't support interactive input)
|
|
if (event.type === "question.requested") {
|
|
const data = event.data as QuestionEventData;
|
|
log(`[Question rejected] ${data.prompt}`);
|
|
await client.rejectQuestion(sessionIdRef.current, data.question_id);
|
|
}
|
|
|
|
// Track when assistant starts thinking
|
|
if (event.type === "item.started") {
|
|
const item = (event.data as any)?.item;
|
|
if (item?.role === "assistant") {
|
|
isThinkingRef.current = true;
|
|
}
|
|
}
|
|
|
|
// Show deltas while assistant is thinking
|
|
if (event.type === "item.delta" && isThinkingRef.current) {
|
|
const delta = (event.data as any)?.delta;
|
|
if (delta) {
|
|
const text = typeof delta === "string" ? delta : delta.type === "text" ? delta.text || "" : "";
|
|
if (text) {
|
|
setOutput((prev) => prev + text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track assistant turn completion
|
|
if (event.type === "item.completed") {
|
|
const item = (event.data as any)?.item;
|
|
if (item?.role === "assistant") {
|
|
isThinkingRef.current = false;
|
|
setOutput((prev) => prev + "\n\n");
|
|
setStatus("ready");
|
|
}
|
|
}
|
|
|
|
// Handle errors
|
|
if (event.type === "error") {
|
|
const data = event.data as any;
|
|
log(`Error: ${data?.message || JSON.stringify(data)}`);
|
|
}
|
|
|
|
// Handle session end
|
|
if (event.type === "session.ended") {
|
|
const data = event.data as any;
|
|
log(`Session ended: ${data?.reason || "unknown"}`);
|
|
setStatus("idle");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
console.error("Event stream error:", err);
|
|
}
|
|
},
|
|
[log]
|
|
);
|
|
|
|
const send = useCallback(async () => {
|
|
if (!clientRef.current || !prompt.trim() || status !== "ready") return;
|
|
|
|
const message = prompt.trim();
|
|
setPrompt("");
|
|
setOutput((prev) => prev + `user: ${message}\n\nassistant: `);
|
|
setStatus("thinking");
|
|
|
|
try {
|
|
await clientRef.current.postMessage(sessionIdRef.current, { message });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setStatus("ready");
|
|
}
|
|
}, [prompt, status]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
abortRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
<h1 style={styles.title}>Sandbox Agent</h1>
|
|
|
|
{status === "idle" && (
|
|
<div style={styles.connectForm}>
|
|
<label style={styles.label}>
|
|
Sandbox name:
|
|
<input
|
|
style={styles.input}
|
|
value={sandboxName}
|
|
onChange={(e) => setSandboxName(e.target.value)}
|
|
placeholder="demo"
|
|
/>
|
|
</label>
|
|
<button style={styles.button} onClick={connect}>
|
|
Connect
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{status === "connecting" && <div style={styles.status}>Connecting to sandbox...</div>}
|
|
|
|
{error && <div style={styles.error}>{error}</div>}
|
|
|
|
{(status === "ready" || status === "thinking") && (
|
|
<>
|
|
<div style={styles.output}>{output}</div>
|
|
<div style={styles.inputRow}>
|
|
<input
|
|
style={styles.promptInput}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
placeholder="Enter prompt..."
|
|
disabled={status === "thinking"}
|
|
/>
|
|
<button style={styles.button} onClick={send} disabled={status === "thinking"}>
|
|
{status === "thinking" ? "..." : "Send"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: {
|
|
fontFamily: "system-ui, sans-serif",
|
|
maxWidth: 800,
|
|
margin: "2rem auto",
|
|
padding: "1rem",
|
|
},
|
|
title: {
|
|
marginBottom: "1rem",
|
|
},
|
|
connectForm: {
|
|
display: "flex",
|
|
gap: "1rem",
|
|
alignItems: "flex-end",
|
|
},
|
|
label: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
color: "#666",
|
|
},
|
|
input: {
|
|
padding: "0.5rem",
|
|
fontSize: "1rem",
|
|
width: 200,
|
|
},
|
|
button: {
|
|
padding: "0.5rem 1rem",
|
|
fontSize: "1rem",
|
|
cursor: "pointer",
|
|
backgroundColor: "#0066cc",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 4,
|
|
},
|
|
status: {
|
|
color: "#666",
|
|
fontStyle: "italic",
|
|
},
|
|
error: {
|
|
color: "#cc0000",
|
|
padding: "0.5rem",
|
|
backgroundColor: "#fff0f0",
|
|
borderRadius: 4,
|
|
marginBottom: "1rem",
|
|
},
|
|
output: {
|
|
whiteSpace: "pre-wrap",
|
|
background: "#1e1e1e",
|
|
color: "#d4d4d4",
|
|
padding: "1rem",
|
|
minHeight: 300,
|
|
fontFamily: "monospace",
|
|
fontSize: 14,
|
|
overflow: "auto",
|
|
borderRadius: 4,
|
|
},
|
|
inputRow: {
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
marginTop: "1rem",
|
|
},
|
|
promptInput: {
|
|
flex: 1,
|
|
padding: "0.5rem",
|
|
fontSize: "1rem",
|
|
},
|
|
};
|