chore: sync workspace changes

This commit is contained in:
Nathan Flurry 2026-01-27 05:06:33 -08:00
parent d24f983e2c
commit bf58891edf
139 changed files with 5454 additions and 8986 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
import { Zap } from "lucide-react";
const ConnectScreen = ({
endpoint,
token,
connectError,
connecting,
onEndpointChange,
onTokenChange,
onConnect,
reportUrl
}: {
endpoint: string;
token: string;
connectError: string | null;
connecting: boolean;
onEndpointChange: (value: string) => void;
onTokenChange: (value: string) => void;
onConnect: () => void;
reportUrl?: string;
}) => {
return (
<div className="app">
<header className="header">
<div className="header-left">
<div className="logo">SA</div>
<span className="header-title">Sandbox Agent</span>
</div>
{reportUrl && (
<div className="header-right">
<a className="button ghost small" href={reportUrl} target="_blank" rel="noreferrer">
Report Bug
</a>
</div>
)}
</header>
<main className="landing">
<div className="landing-container">
<div className="landing-hero">
<div className="landing-logo">SA</div>
<h1 className="landing-title">Sandbox Agent</h1>
<p className="landing-subtitle">
Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes.
</p>
</div>
<div className="connect-card">
<div className="connect-card-title">Connect to Server</div>
{connectError && <div className="banner error">{connectError}</div>}
<label className="field">
<span className="label">Endpoint</span>
<input
className="input"
type="text"
placeholder="http://localhost:2468"
value={endpoint}
onChange={(event) => onEndpointChange(event.target.value)}
/>
</label>
<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)}
/>
</label>
<button className="button primary" onClick={onConnect} disabled={connecting}>
{connecting ? (
<>
<span className="spinner" />
Connecting...
</>
) : (
<>
<Zap className="button-icon" />
Connect
</>
)}
</button>
<p className="hint">
Start the server with CORS enabled for browser access:
<br />
<code>sandbox-agent server --cors-allow-origin http://localhost:5173</code>
</p>
</div>
</div>
</main>
</div>
);
};
export default ConnectScreen;

View file

@ -0,0 +1,55 @@
import { Plus, RefreshCw } from "lucide-react";
import type { SessionInfo } from "sandbox-agent";
const SessionSidebar = ({
sessions,
selectedSessionId,
onSelectSession,
onRefresh,
onCreateSession
}: {
sessions: SessionInfo[];
selectedSessionId: string;
onSelectSession: (session: SessionInfo) => void;
onRefresh: () => void;
onCreateSession: () => void;
}) => {
return (
<div className="session-sidebar">
<div className="sidebar-header">
<span className="sidebar-title">Sessions</span>
<div className="sidebar-header-actions">
<button className="sidebar-icon-btn" onClick={onRefresh} title="Refresh sessions">
<RefreshCw size={14} />
</button>
<button className="sidebar-add-btn" onClick={onCreateSession} title="New session">
<Plus size={14} />
</button>
</div>
</div>
<div className="session-list">
{sessions.length === 0 ? (
<div className="sidebar-empty">No sessions yet.</div>
) : (
sessions.map((session) => (
<button
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""}`}
onClick={() => onSelectSession(session)}
>
<div className="session-item-id">{session.sessionId}</div>
<div className="session-item-meta">
<span className="session-item-agent">{session.agent}</span>
<span className="session-item-events">{session.eventCount} events</span>
{session.ended && <span className="session-item-ended">ended</span>}
</div>
</button>
))
)}
</div>
</div>
);
};
export default SessionSidebar;

View file

@ -0,0 +1,57 @@
import type { ComponentType } from "react";
import {
Activity,
AlertTriangle,
Brain,
Download,
FileDiff,
GitBranch,
HelpCircle,
Image,
MessageSquare,
Paperclip,
PlayCircle,
Plug,
Shield,
Terminal,
Wrench
} from "lucide-react";
import type { AgentCapabilitiesView } from "../../types/agents";
const badges = [
{ key: "planMode", label: "Plan", icon: GitBranch },
{ key: "permissions", label: "Perms", icon: Shield },
{ key: "questions", label: "Q&A", icon: HelpCircle },
{ key: "toolCalls", label: "Tool Calls", icon: Wrench },
{ key: "toolResults", label: "Tool Results", icon: Download },
{ key: "textMessages", label: "Text", icon: MessageSquare },
{ key: "images", label: "Images", icon: Image },
{ key: "fileAttachments", label: "Files", icon: Paperclip },
{ key: "sessionLifecycle", label: "Lifecycle", icon: PlayCircle },
{ key: "errorEvents", label: "Errors", icon: AlertTriangle },
{ key: "reasoning", label: "Reasoning", icon: Brain },
{ key: "commandExecution", label: "Commands", icon: Terminal },
{ key: "fileChanges", label: "File Changes", icon: FileDiff },
{ key: "mcpTools", label: "MCP", icon: Plug },
{ key: "streamingDeltas", label: "Deltas", icon: Activity }
] as const;
type BadgeItem = (typeof badges)[number];
const getEnabled = (capabilities: AgentCapabilitiesView, key: BadgeItem["key"]) =>
Boolean((capabilities as Record<string, boolean | undefined>)[key]);
const CapabilityBadges = ({ capabilities }: { capabilities: AgentCapabilitiesView }) => {
return (
<div className="capability-badges">
{badges.map(({ key, label, icon: Icon }) => (
<span key={key} className={`capability-badge ${getEnabled(capabilities, key) ? "enabled" : "disabled"}`}>
<Icon size={12} />
<span>{label}</span>
</span>
))}
</div>
);
};
export default CapabilityBadges;

View file

@ -0,0 +1,37 @@
import { Send } from "lucide-react";
const ChatInput = ({
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled
}: {
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
}) => {
return (
<div className="input-container">
<div className="input-wrapper">
<textarea
value={message}
onChange={(event) => onMessageChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={1}
disabled={disabled}
/>
<button className="send-button" onClick={onSendMessage} disabled={disabled || !message.trim()}>
<Send />
</button>
</div>
</div>
);
};
export default ChatInput;

View file

@ -0,0 +1,75 @@
import { getAvatarLabel, getMessageClass } from "./messageUtils";
import renderContentPart from "./renderContentPart";
import type { TimelineEntry } from "./types";
const ChatMessages = ({
entries,
sessionError,
messagesEndRef
}: {
entries: TimelineEntry[];
sessionError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
}) => {
return (
<div className="messages">
{entries.map((entry) => {
if (entry.kind === "meta") {
const messageClass = entry.meta?.severity === "error" ? "error" : "system";
return (
<div key={entry.id} className={`message ${messageClass}`}>
<div className="avatar">{getAvatarLabel(messageClass)}</div>
<div className="message-content">
<div className="message-meta">
<span>{entry.meta?.title ?? "Status"}</span>
</div>
{entry.meta?.detail && <div className="part-body">{entry.meta.detail}</div>}
</div>
</div>
);
}
const item = entry.item;
if (!item) return null;
const hasParts = (item.content ?? []).length > 0;
const isInProgress = item.status === "in_progress";
const isFailed = item.status === "failed";
const messageClass = getMessageClass(item);
const statusLabel = item.status !== "completed" ? item.status.replace("_", " ") : "";
const kindLabel = item.kind.replace("_", " ");
return (
<div key={entry.id} className={`message ${messageClass} ${isFailed ? "error" : ""}`}>
<div className="avatar">{getAvatarLabel(isFailed ? "error" : messageClass)}</div>
<div className="message-content">
{(item.kind !== "message" || item.status !== "completed") && (
<div className="message-meta">
<span>{kindLabel}</span>
{statusLabel && (
<span className={`pill ${item.status === "failed" ? "danger" : "accent"}`}>
{statusLabel}
</span>
)}
</div>
)}
{hasParts ? (
(item.content ?? []).map(renderContentPart)
) : entry.deltaText ? (
<span>
{entry.deltaText}
{isInProgress && <span className="cursor" />}
</span>
) : (
<span className="muted">No content yet.</span>
)}
</div>
</div>
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
<div ref={messagesEndRef} />
</div>
);
};
export default ChatMessages;

View file

@ -0,0 +1,164 @@
import { MessageSquare, Plus, Terminal } from "lucide-react";
import type { AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import ApprovalsTab from "../debug/ApprovalsTab";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import ChatSetup from "./ChatSetup";
import type { TimelineEntry } from "./types";
const ChatPanel = ({
sessionId,
polling,
transcriptEntries,
sessionError,
message,
onMessageChange,
onSendMessage,
onKeyDown,
onCreateSession,
messagesEndRef,
agentId,
agentMode,
permissionMode,
model,
variant,
streamMode,
availableAgents,
activeModes,
currentAgentVersion,
onAgentChange,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream,
questionRequests,
permissionRequests,
questionSelections,
onSelectQuestionOption,
onAnswerQuestion,
onRejectQuestion,
onReplyPermission
}: {
sessionId: string;
polling: boolean;
transcriptEntries: TimelineEntry[];
sessionError: string | null;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
streamMode: "poll" | "sse";
availableAgents: string[];
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
onAgentChange: (value: string) => void;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse") => void;
onToggleStream: () => void;
questionRequests: QuestionEventData[];
permissionRequests: PermissionEventData[];
questionSelections: Record<string, string[][]>;
onSelectQuestionOption: (requestId: string, optionLabel: string) => void;
onAnswerQuestion: (request: QuestionEventData) => void;
onRejectQuestion: (requestId: string) => void;
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
}) => {
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
return (
<div className="chat-panel">
<div className="panel-header">
<div className="panel-header-left">
<MessageSquare className="button-icon" />
<span className="panel-title">Session</span>
{sessionId && <span className="session-id-display">{sessionId}</span>}
</div>
{polling && <span className="pill accent">Live</span>}
</div>
<div className="messages-container">
{!sessionId ? (
<div className="empty-state">
<MessageSquare className="empty-state-icon" />
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text">Create a new session to start chatting with an agent.</p>
<button className="button primary" onClick={onCreateSession}>
<Plus className="button-icon" />
Create Session
</button>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div>
) : (
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
messagesEndRef={messagesEndRef}
/>
)}
</div>
{hasApprovals && (
<div className="approvals-inline">
<div className="approvals-inline-header">Approvals</div>
<ApprovalsTab
questionRequests={questionRequests}
permissionRequests={permissionRequests}
questionSelections={questionSelections}
onSelectQuestionOption={onSelectQuestionOption}
onAnswerQuestion={onAnswerQuestion}
onRejectQuestion={onRejectQuestion}
onReplyPermission={onReplyPermission}
/>
</div>
)}
<ChatInput
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId}
/>
<ChatSetup
agentId={agentId}
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
streamMode={streamMode}
polling={polling}
availableAgents={availableAgents}
activeModes={activeModes}
currentAgentVersion={currentAgentVersion}
onAgentChange={onAgentChange}
onAgentModeChange={onAgentModeChange}
onPermissionModeChange={onPermissionModeChange}
onModelChange={onModelChange}
onVariantChange={onVariantChange}
onStreamModeChange={onStreamModeChange}
onToggleStream={onToggleStream}
/>
</div>
);
};
export default ChatPanel;

View file

@ -0,0 +1,133 @@
import { PauseCircle, PlayCircle } from "lucide-react";
import type { AgentModeInfo } from "sandbox-agent";
const ChatSetup = ({
agentId,
agentMode,
permissionMode,
model,
variant,
streamMode,
polling,
availableAgents,
activeModes,
currentAgentVersion,
onAgentChange,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream
}: {
agentId: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
streamMode: "poll" | "sse";
polling: boolean;
availableAgents: string[];
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
onAgentChange: (value: string) => void;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse") => void;
onToggleStream: () => void;
}) => {
return (
<div className="setup-row">
<select className="setup-select" value={agentId} onChange={(e) => onAgentChange(e.target.value)} title="Agent">
{availableAgents.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
<select
className="setup-select"
value={agentMode}
onChange={(e) => onAgentModeChange(e.target.value)}
title="Mode"
>
{activeModes.length > 0 ? (
activeModes.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.name || mode.id}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => onPermissionModeChange(e.target.value)}
title="Permission Mode"
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
<input
className="setup-input"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
/>
<input
className="setup-input"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
placeholder="Variant"
title="Variant"
/>
<div className="setup-stream">
<select
className="setup-select-small"
value={streamMode}
onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse")}
title="Stream Mode"
>
<option value="poll">Poll</option>
<option value="sse">SSE</option>
</select>
<button
className={`setup-stream-btn ${polling ? "active" : ""}`}
onClick={onToggleStream}
title={polling ? "Stop streaming" : "Start streaming"}
>
{polling ? (
<>
<PauseCircle size={14} />
<span>Pause</span>
</>
) : (
<>
<PlayCircle size={14} />
<span>Resume</span>
</>
)}
</button>
</div>
{currentAgentVersion && (
<span className="setup-version" title="Installed version">
v{currentAgentVersion}
</span>
)}
</div>
);
};
export default ChatSetup;

View file

@ -0,0 +1,18 @@
import type { UniversalItem } from "sandbox-agent";
export const getMessageClass = (item: UniversalItem) => {
if (item.kind === "tool_call" || item.kind === "tool_result") return "tool";
if (item.kind === "system" || item.kind === "status") return "system";
if (item.role === "user") return "user";
if (item.role === "tool") return "tool";
if (item.role === "system") return "system";
return "assistant";
};
export const getAvatarLabel = (messageClass: string) => {
if (messageClass === "user") return "U";
if (messageClass === "tool") return "T";
if (messageClass === "system") return "S";
if (messageClass === "error") return "!";
return "AI";
};

View file

@ -0,0 +1,93 @@
import type { ContentPart } from "sandbox-agent";
import { formatJson } from "../../utils/format";
const renderContentPart = (part: ContentPart, index: number) => {
const partType = (part as { type?: string }).type ?? "unknown";
const key = `${partType}-${index}`;
switch (partType) {
case "text":
return (
<div key={key} className="part">
<div className="part-body">{(part as { text: string }).text}</div>
</div>
);
case "json":
return (
<div key={key} className="part">
<div className="part-title">json</div>
<pre className="code-block">{formatJson((part as { json: unknown }).json)}</pre>
</div>
);
case "tool_call": {
const { name, arguments: args, call_id } = part as {
name: string;
arguments: string;
call_id: string;
};
return (
<div key={key} className="part">
<div className="part-title">
tool call - {name}
{call_id ? ` - ${call_id}` : ""}
</div>
{args ? <pre className="code-block">{args}</pre> : <div className="muted">No arguments</div>}
</div>
);
}
case "tool_result": {
const { call_id, output } = part as { call_id: string; output: string };
return (
<div key={key} className="part">
<div className="part-title">tool result - {call_id}</div>
{output ? <pre className="code-block">{output}</pre> : <div className="muted">No output</div>}
</div>
);
}
case "file_ref": {
const { path, action, diff } = part as { path: string; action: string; diff?: string | null };
return (
<div key={key} className="part">
<div className="part-title">file - {action}</div>
<div className="part-body mono">{path}</div>
{diff && <pre className="code-block">{diff}</pre>}
</div>
);
}
case "reasoning": {
const { text, visibility } = part as { text: string; visibility: string };
return (
<div key={key} className="part">
<div className="part-title">reasoning - {visibility}</div>
<div className="part-body muted">{text}</div>
</div>
);
}
case "image": {
const { path, mime } = part as { path: string; mime?: string | null };
return (
<div key={key} className="part">
<div className="part-title">image {mime ? `- ${mime}` : ""}</div>
<div className="part-body mono">{path}</div>
</div>
);
}
case "status": {
const { label, detail } = part as { label: string; detail?: string | null };
return (
<div key={key} className="part">
<div className="part-title">status - {label}</div>
{detail && <div className="part-body">{detail}</div>}
</div>
);
}
default:
return (
<div key={key} className="part">
<div className="part-title">unknown</div>
<pre className="code-block">{formatJson(part)}</pre>
</div>
);
}
};
export default renderContentPart;

View file

@ -0,0 +1,14 @@
import type { UniversalItem } from "sandbox-agent";
export type TimelineEntry = {
id: string;
kind: "item" | "meta";
time: string;
item?: UniversalItem;
deltaText?: string;
meta?: {
title: string;
detail?: string;
severity?: "info" | "error";
};
};

View file

@ -0,0 +1,71 @@
import { Download, RefreshCw } from "lucide-react";
import type { AgentInfo, AgentModeInfo } from "sandbox-agent";
import CapabilityBadges from "../agents/CapabilityBadges";
import { emptyCapabilities } from "../../types/agents";
const AgentsTab = ({
agents,
defaultAgents,
modesByAgent,
onRefresh,
onInstall
}: {
agents: AgentInfo[];
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefresh: () => void;
onInstall: (agentId: string, reinstall: boolean) => void;
}) => {
return (
<>
<div className="inline-row" style={{ marginBottom: 16 }}>
<button className="button secondary small" onClick={onRefresh}>
<RefreshCw className="button-icon" /> Refresh
</button>
</div>
{agents.length === 0 && <div className="card-meta">No agents reported. Click refresh to check.</div>}
{(agents.length
? agents
: defaultAgents.map((id) => ({
id,
installed: false,
version: undefined,
path: undefined,
capabilities: emptyCapabilities
}))).map((agent) => (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
</div>
<div className="card-meta">
{agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div style={{ marginTop: 8 }}>
<CapabilityBadges capabilities={agent.capabilities ?? emptyCapabilities} />
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
</div>
)}
<div className="card-actions">
<button className="button secondary small" onClick={() => onInstall(agent.id, false)}>
<Download className="button-icon" /> Install
</button>
<button className="button ghost small" onClick={() => onInstall(agent.id, true)}>
Reinstall
</button>
</div>
</div>
))}
</>
);
};
export default AgentsTab;

View file

@ -0,0 +1,105 @@
import { HelpCircle, Shield } from "lucide-react";
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
import { formatJson } from "../../utils/format";
const ApprovalsTab = ({
questionRequests,
permissionRequests,
questionSelections,
onSelectQuestionOption,
onAnswerQuestion,
onRejectQuestion,
onReplyPermission
}: {
questionRequests: QuestionEventData[];
permissionRequests: PermissionEventData[];
questionSelections: Record<string, string[][]>;
onSelectQuestionOption: (requestId: string, optionLabel: string) => void;
onAnswerQuestion: (request: QuestionEventData) => void;
onRejectQuestion: (requestId: string) => void;
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
}) => {
return (
<>
{questionRequests.length === 0 && permissionRequests.length === 0 ? (
<div className="card-meta">No pending approvals.</div>
) : (
<>
{questionRequests.map((request) => {
const selections = questionSelections[request.question_id] ?? [];
const selected = selections[0] ?? [];
const answered = selected.length > 0;
return (
<div key={request.question_id} className="card">
<div className="card-header">
<span className="card-title">
<HelpCircle className="button-icon" style={{ marginRight: 6 }} />
Question
</span>
<span className="pill accent">Pending</span>
</div>
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 12, marginBottom: 8 }}>{request.prompt}</div>
<div className="option-list">
{request.options.map((option) => {
const isSelected = selected.includes(option);
return (
<label key={option} className="option-item">
<input
type="radio"
checked={isSelected}
onChange={() => onSelectQuestionOption(request.question_id, option)}
/>
<span>{option}</span>
</label>
);
})}
</div>
</div>
<div className="card-actions">
<button className="button success small" disabled={!answered} onClick={() => onAnswerQuestion(request)}>
Reply
</button>
<button className="button danger small" onClick={() => onRejectQuestion(request.question_id)}>
Reject
</button>
</div>
</div>
);
})}
{permissionRequests.map((request) => (
<div key={request.permission_id} className="card">
<div className="card-header">
<span className="card-title">
<Shield className="button-icon" style={{ marginRight: 6 }} />
Permission
</span>
<span className="pill accent">Pending</span>
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
{request.action}
</div>
{request.metadata !== null && request.metadata !== undefined && (
<pre className="code-block">{formatJson(request.metadata)}</pre>
)}
<div className="card-actions">
<button className="button success small" onClick={() => onReplyPermission(request.permission_id, "once")}>
Allow Once
</button>
<button className="button secondary small" onClick={() => onReplyPermission(request.permission_id, "always")}>
Always
</button>
<button className="button danger small" onClick={() => onReplyPermission(request.permission_id, "reject")}>
Reject
</button>
</div>
</div>
))}
</>
)}
</>
);
};
export default ApprovalsTab;

View file

@ -0,0 +1,89 @@
import { Cloud, PlayCircle, Terminal } from "lucide-react";
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "sandbox-agent";
import AgentsTab from "./AgentsTab";
import EventsTab from "./EventsTab";
import RequestLogTab from "./RequestLogTab";
import type { RequestLog } from "../../types/requestLog";
export type DebugTab = "log" | "events" | "agents";
const DebugPanel = ({
debugTab,
onDebugTabChange,
events,
offset,
onFetchEvents,
onResetEvents,
requestLog,
copiedLogId,
onClearRequestLog,
onCopyRequestLog,
agents,
defaultAgents,
modesByAgent,
onRefreshAgents,
onInstallAgent
}: {
debugTab: DebugTab;
onDebugTabChange: (tab: DebugTab) => void;
events: UniversalEvent[];
offset: number;
onFetchEvents: () => void;
onResetEvents: () => void;
requestLog: RequestLog[];
copiedLogId: number | null;
onClearRequestLog: () => void;
onCopyRequestLog: (entry: RequestLog) => void;
agents: AgentInfo[];
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefreshAgents: () => void;
onInstallAgent: (agentId: string, reinstall: boolean) => void;
}) => {
return (
<div className="debug-panel">
<div className="debug-tabs">
<button className={`debug-tab ${debugTab === "events" ? "active" : ""}`} onClick={() => onDebugTabChange("events")}>
<PlayCircle className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Events
{events.length > 0 && <span className="debug-tab-badge">{events.length}</span>}
</button>
<button className={`debug-tab ${debugTab === "log" ? "active" : ""}`} onClick={() => onDebugTabChange("log")}>
<Terminal className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Request Log
</button>
<button className={`debug-tab ${debugTab === "agents" ? "active" : ""}`} onClick={() => onDebugTabChange("agents")}>
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Agents
</button>
</div>
<div className="debug-content">
{debugTab === "log" && (
<RequestLogTab
requestLog={requestLog}
copiedLogId={copiedLogId}
onClear={onClearRequestLog}
onCopy={onCopyRequestLog}
/>
)}
{debugTab === "events" && (
<EventsTab events={events} offset={offset} onFetch={onFetchEvents} onClear={onResetEvents} />
)}
{debugTab === "agents" && (
<AgentsTab
agents={agents}
defaultAgents={defaultAgents}
modesByAgent={modesByAgent}
onRefresh={onRefreshAgents}
onInstall={onInstallAgent}
/>
)}
</div>
</div>
);
};
export default DebugPanel;

View file

@ -0,0 +1,91 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import type { UniversalEvent } from "sandbox-agent";
import { formatJson, formatTime } from "../../utils/format";
import { getEventCategory, getEventClass, getEventIcon, getEventKey, getEventType } from "./eventUtils";
const EventsTab = ({
events,
offset,
onFetch,
onClear
}: {
events: UniversalEvent[];
offset: number;
onFetch: () => void;
onClear: () => void;
}) => {
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
useEffect(() => {
if (events.length === 0) {
setCollapsedEvents({});
}
}, [events.length]);
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Offset: {offset}</span>
<div className="inline-row">
<button className="button ghost small" onClick={onFetch}>
Fetch
</button>
<button className="button ghost small" onClick={onClear}>
Clear
</button>
</div>
</div>
{events.length === 0 ? (
<div className="card-meta">No events yet. Start streaming to receive events.</div>
) : (
<div className="event-list">
{[...events].reverse().map((event) => {
const type = getEventType(event);
const category = getEventCategory(type);
const eventClass = `${category} ${getEventClass(type)}`;
const eventKey = getEventKey(event);
const isCollapsed = collapsedEvents[eventKey] ?? true;
const toggleCollapsed = () =>
setCollapsedEvents((prev) => ({
...prev,
[eventKey]: !(prev[eventKey] ?? true)
}));
const Icon = getEventIcon(type);
return (
<div key={eventKey} className={`event-item ${isCollapsed ? "collapsed" : "expanded"}`}>
<button
className="event-summary"
type="button"
onClick={toggleCollapsed}
title={isCollapsed ? "Expand payload" : "Collapse payload"}
>
<span className={`event-icon ${eventClass}`}>
<Icon size={14} />
</span>
<div className="event-summary-main">
<div className="event-title-row">
<span className={`event-type ${eventClass}`}>{type}</span>
<span className="event-time">{formatTime(event.time)}</span>
</div>
<div className="event-id">
Event #{event.event_id || event.sequence} - seq {event.sequence} - {event.source}
{event.synthetic ? " (synthetic)" : ""}
</div>
</div>
<span className="event-chevron">
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</span>
</button>
{!isCollapsed && <pre className="code-block event-payload">{formatJson(event.data)}</pre>}
</div>
);
})}
</div>
)}
</>
);
};
export default EventsTab;

View file

@ -0,0 +1,52 @@
import { Clipboard } from "lucide-react";
import type { RequestLog } from "../../types/requestLog";
const RequestLogTab = ({
requestLog,
copiedLogId,
onClear,
onCopy
}: {
requestLog: RequestLog[];
copiedLogId: number | null;
onClear: () => void;
onCopy: (entry: RequestLog) => void;
}) => {
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">{requestLog.length} requests</span>
<button className="button ghost small" onClick={onClear}>
Clear
</button>
</div>
{requestLog.length === 0 ? (
<div className="card-meta">No requests logged yet.</div>
) : (
requestLog.map((entry) => (
<div key={entry.id} className="log-item">
<span className="log-method">{entry.method}</span>
<span className="log-url text-truncate">{entry.url}</span>
<span className={`log-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</span>
<div className="log-meta">
<span>
{entry.time}
{entry.error && ` - ${entry.error}`}
</span>
<button className="copy-button" onClick={() => onCopy(entry)}>
<Clipboard />
{copiedLogId === entry.id ? "Copied" : "curl"}
</button>
</div>
</div>
))
)}
</>
);
};
export default RequestLogTab;

View file

@ -0,0 +1,63 @@
import {
Activity,
AlertTriangle,
Brain,
CheckCircle,
FileDiff,
HelpCircle,
MessageSquare,
PauseCircle,
PlayCircle,
Shield,
Terminal,
Wrench,
Zap
} from "lucide-react";
import type { UniversalEvent } from "sandbox-agent";
export const getEventType = (event: UniversalEvent) => event.type;
export const getEventKey = (event: UniversalEvent) =>
event.event_id ? `id:${event.event_id}` : `seq:${event.sequence}`;
export const getEventCategory = (type: string) => type.split(".")[0] ?? type;
export const getEventClass = (type: string) => type.replace(/\./g, "-");
export const getEventIcon = (type: string) => {
switch (type) {
case "session.started":
return PlayCircle;
case "session.ended":
return PauseCircle;
case "item.started":
return MessageSquare;
case "item.delta":
return Activity;
case "item.completed":
return CheckCircle;
case "question.requested":
return HelpCircle;
case "question.resolved":
return CheckCircle;
case "permission.requested":
return Shield;
case "permission.resolved":
return CheckCircle;
case "error":
return AlertTriangle;
case "agent.unparsed":
return Brain;
default:
if (type.startsWith("item.")) return MessageSquare;
if (type.startsWith("session.")) return PlayCircle;
if (type.startsWith("error")) return AlertTriangle;
if (type.startsWith("agent.")) return Brain;
if (type.startsWith("question.")) return HelpCircle;
if (type.startsWith("permission.")) return Shield;
if (type.startsWith("file.")) return FileDiff;
if (type.startsWith("command.")) return Terminal;
if (type.startsWith("tool.")) return Wrench;
return Zap;
}
};

View file

@ -0,0 +1,33 @@
import type { AgentCapabilities } from "sandbox-agent";
export type AgentCapabilitiesView = AgentCapabilities & {
toolResults?: boolean;
textMessages?: boolean;
images?: boolean;
fileAttachments?: boolean;
sessionLifecycle?: boolean;
errorEvents?: boolean;
reasoning?: boolean;
commandExecution?: boolean;
fileChanges?: boolean;
mcpTools?: boolean;
streamingDeltas?: boolean;
};
export const emptyCapabilities: AgentCapabilitiesView = {
planMode: false,
permissions: false,
questions: false,
toolCalls: false,
toolResults: false,
textMessages: false,
images: false,
fileAttachments: false,
sessionLifecycle: false,
errorEvents: false,
reasoning: false,
commandExecution: false,
fileChanges: false,
mcpTools: false,
streamingDeltas: false
};

View file

@ -0,0 +1,10 @@
export type RequestLog = {
id: number;
method: string;
url: string;
body?: string;
status?: number;
time: string;
curl: string;
error?: string;
};

View file

@ -0,0 +1,18 @@
export const formatJson = (value: unknown) => {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
export const formatTime = (value: string) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleTimeString();
};
export const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`);

View file

@ -0,0 +1,15 @@
import { escapeSingleQuotes } from "./format";
export const buildCurl = (method: string, url: string, body?: string, token?: string) => {
const headers: string[] = [];
if (token) {
headers.push(`-H 'Authorization: Bearer ${escapeSingleQuotes(token)}'`);
}
if (body) {
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();
};