chore: fix bad merge

This commit is contained in:
Nathan Flurry 2026-02-11 07:52:48 -08:00
parent 1dd45908a3
commit 94353f7696
205 changed files with 19244 additions and 14866 deletions

View file

@ -1,4 +1,4 @@
import { AlertTriangle, Zap } from "lucide-react";
import { AlertTriangle, BookOpen, Zap } from "lucide-react";
import { isHttpsToHttpConnection, isLocalNetworkTarget } from "../lib/permissions";
const logoUrl = `${import.meta.env.BASE_URL}logos/sandboxagent.svg`;
@ -11,7 +11,9 @@ const ConnectScreen = ({
onEndpointChange,
onTokenChange,
onConnect,
reportUrl
reportUrl,
docsUrl,
discordUrl,
}: {
endpoint: string;
token: string;
@ -21,6 +23,8 @@ const ConnectScreen = ({
onTokenChange: (value: string) => void;
onConnect: () => void;
reportUrl?: string;
docsUrl?: string;
discordUrl?: string;
}) => {
return (
<div className="app">
@ -28,11 +32,26 @@ const ConnectScreen = ({
<div className="header-left">
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
</div>
{reportUrl && (
{(docsUrl || discordUrl || reportUrl) && (
<div className="header-right">
<a className="button ghost small" href={reportUrl} target="_blank" rel="noreferrer">
Report Bug
</a>
{docsUrl && (
<a className="header-link" href={docsUrl} target="_blank" rel="noreferrer">
<BookOpen size={12} />
Docs
</a>
)}
{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>
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>
Issues
</a>
)}
</div>
)}
</header>

View file

@ -1,15 +1,17 @@
import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { McpServerEntry } from "../App";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "../types/legacyApi";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useState } from "react";
import type { AgentInfo } from "sandbox-agent";
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
export type SessionConfig = {
model: string;
agentMode: string;
permissionMode: string;
variant: string;
model: string;
};
const CUSTOM_MODEL_VALUE = "__custom__";
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
@ -17,59 +19,6 @@ const agentLabels: Record<string, string> = {
amp: "Amp"
};
const validateServerJson = (json: string): string | null => {
const trimmed = json.trim();
if (!trimmed) return "Config is required";
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return "Must be a JSON object";
}
if (!parsed.type) return 'Missing "type" field';
if (parsed.type !== "local" && parsed.type !== "remote") {
return 'Type must be "local" or "remote"';
}
if (parsed.type === "local" && !parsed.command) return 'Local server requires "command"';
if (parsed.type === "remote" && !parsed.url) return 'Remote server requires "url"';
return null;
} catch {
return "Invalid JSON";
}
};
const getServerType = (configJson: string): string | null => {
try {
const parsed = JSON.parse(configJson);
return parsed?.type ?? null;
} catch {
return null;
}
};
const getServerSummary = (configJson: string): string => {
try {
const parsed = JSON.parse(configJson);
if (parsed?.type === "local") {
const cmd = Array.isArray(parsed.command) ? parsed.command.join(" ") : parsed.command;
return cmd ?? "local";
}
if (parsed?.type === "remote") {
return parsed.url ?? "remote";
}
return parsed?.type ?? "";
} catch {
return "";
}
};
const skillSourceSummary = (source: SkillSource): string => {
let summary = source.source;
if (source.skills && source.skills.length > 0) {
summary += ` [${source.skills.join(", ")}]`;
}
return summary;
};
const SessionCreateMenu = ({
agents,
agentsLoading,
@ -77,17 +26,8 @@ const SessionCreateMenu = ({
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange,
onSelectAgent,
onCreateSession,
onSelectAgent,
open,
onClose
}: {
@ -97,60 +37,18 @@ const SessionCreateMenu = ({
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
onSelectAgent: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => Promise<void>;
open: boolean;
onClose: () => void;
}) => {
const [phase, setPhase] = useState<"agent" | "config">("agent");
const [phase, setPhase] = useState<"agent" | "config" | "loading-config">("agent");
const [selectedAgent, setSelectedAgent] = useState("");
const [agentMode, setAgentMode] = useState("");
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [mcpExpanded, setMcpExpanded] = useState(false);
const [skillsExpanded, setSkillsExpanded] = useState(false);
// Skill add/edit state
const [addingSkill, setAddingSkill] = useState(false);
const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(null);
const [skillType, setSkillType] = useState<"github" | "local" | "git">("github");
const [skillSource, setSkillSource] = useState("");
const [skillFilter, setSkillFilter] = useState("");
const [skillRef, setSkillRef] = useState("");
const [skillSubpath, setSkillSubpath] = useState("");
const [skillLocalError, setSkillLocalError] = useState<string | null>(null);
const skillSourceRef = useRef<HTMLInputElement>(null);
// MCP add/edit state
const [addingMcp, setAddingMcp] = useState(false);
const [editingMcpIndex, setEditingMcpIndex] = useState<number | null>(null);
const [mcpName, setMcpName] = useState("");
const [mcpJson, setMcpJson] = useState("");
const [mcpLocalError, setMcpLocalError] = useState<string | null>(null);
const mcpNameRef = useRef<HTMLInputElement>(null);
const mcpJsonRef = useRef<HTMLTextAreaElement>(null);
const cancelSkillEdit = () => {
setAddingSkill(false);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("");
setSkillFilter("");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
const [selectedModel, setSelectedModel] = useState("");
const [customModel, setCustomModel] = useState("");
const [isCustomModel, setIsCustomModel] = useState(false);
const [configLoadDone, setConfigLoadDone] = useState(false);
// Reset state when menu closes
useEffect(() => {
@ -158,20 +56,21 @@ const SessionCreateMenu = ({
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
setMcpExpanded(false);
setSkillsExpanded(false);
cancelSkillEdit();
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
setSelectedModel("");
setCustomModel("");
setIsCustomModel(false);
setConfigLoadDone(false);
}
}, [open]);
// Transition to config phase after load completes — deferred via useEffect
// so parent props (modelsByAgent) have settled before we render the config form
useEffect(() => {
if (phase === "loading-config" && configLoadDone) {
setPhase("config");
}
}, [phase, configLoadDone]);
// Auto-select first mode when modes load for selected agent
useEffect(() => {
if (!selectedAgent) return;
@ -181,174 +80,60 @@ const SessionCreateMenu = ({
}
}, [modesByAgent, selectedAgent, agentMode]);
// Focus skill source input when adding
// Auto-select default model when agent is selected
useEffect(() => {
if ((addingSkill || editingSkillIndex !== null) && skillSourceRef.current) {
skillSourceRef.current.focus();
if (!selectedAgent) return;
if (selectedModel) return;
const defaultModel = defaultModelByAgent[selectedAgent];
if (defaultModel) {
setSelectedModel(defaultModel);
} else {
const models = modelsByAgent[selectedAgent];
if (models && models.length > 0) {
setSelectedModel(models[0].id);
}
}
}, [addingSkill, editingSkillIndex]);
// Focus MCP name input when adding
useEffect(() => {
if (addingMcp && mcpNameRef.current) {
mcpNameRef.current.focus();
}
}, [addingMcp]);
// Focus MCP json textarea when editing
useEffect(() => {
if (editingMcpIndex !== null && mcpJsonRef.current) {
mcpJsonRef.current.focus();
}
}, [editingMcpIndex]);
}, [modelsByAgent, defaultModelByAgent, selectedAgent, selectedModel]);
if (!open) return null;
const handleAgentClick = (agentId: string) => {
setSelectedAgent(agentId);
setPhase("config");
onSelectAgent(agentId);
setPhase("loading-config");
setConfigLoadDone(false);
onSelectAgent(agentId).finally(() => {
setConfigLoadDone(true);
});
};
const handleBack = () => {
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
setSelectedModel("");
setCustomModel("");
setIsCustomModel(false);
setConfigLoadDone(false);
};
const handleModelSelectChange = (value: string) => {
if (value === CUSTOM_MODEL_VALUE) {
setIsCustomModel(true);
setSelectedModel("");
} else {
setIsCustomModel(false);
setCustomModel("");
setSelectedModel(value);
}
};
const resolvedModel = isCustomModel ? customModel : selectedModel;
const handleCreate = () => {
if (mcpConfigError) return;
onCreateSession(selectedAgent, { model, agentMode, permissionMode, variant });
onCreateSession(selectedAgent, { agentMode, model: resolvedModel });
onClose();
};
// Skill source helpers
const startAddSkill = () => {
setAddingSkill(true);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("rivet-dev/skills");
setSkillFilter("sandbox-agent");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
const startEditSkill = (index: number) => {
const entry = skillSources[index];
setEditingSkillIndex(index);
setAddingSkill(false);
setSkillType(entry.type as "github" | "local" | "git");
setSkillSource(entry.source);
setSkillFilter(entry.skills?.join(", ") ?? "");
setSkillRef(entry.ref ?? "");
setSkillSubpath(entry.subpath ?? "");
setSkillLocalError(null);
};
const commitSkill = () => {
const src = skillSource.trim();
if (!src) {
setSkillLocalError("Source is required");
return;
}
const entry: SkillSource = {
type: skillType,
source: src,
};
const filterList = skillFilter.trim()
? skillFilter.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
if (filterList && filterList.length > 0) entry.skills = filterList;
if (skillRef.trim()) entry.ref = skillRef.trim();
if (skillSubpath.trim()) entry.subpath = skillSubpath.trim();
if (editingSkillIndex !== null) {
const updated = [...skillSources];
updated[editingSkillIndex] = entry;
onSkillSourcesChange(updated);
} else {
onSkillSourcesChange([...skillSources, entry]);
}
cancelSkillEdit();
};
const removeSkill = (index: number) => {
onSkillSourcesChange(skillSources.filter((_, i) => i !== index));
if (editingSkillIndex === index) {
cancelSkillEdit();
}
};
const isEditingSkill = addingSkill || editingSkillIndex !== null;
const startAddMcp = () => {
setAddingMcp(true);
setEditingMcpIndex(null);
setMcpName("everything");
setMcpJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}');
setMcpLocalError(null);
};
const startEditMcp = (index: number) => {
const entry = mcpServers[index];
setEditingMcpIndex(index);
setAddingMcp(false);
setMcpName(entry.name);
setMcpJson(entry.configJson);
setMcpLocalError(entry.error);
};
const cancelMcpEdit = () => {
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
};
const commitMcp = () => {
const name = mcpName.trim();
if (!name) {
setMcpLocalError("Server name is required");
return;
}
const error = validateServerJson(mcpJson);
if (error) {
setMcpLocalError(error);
return;
}
// Check for duplicate names (except when editing the same entry)
const duplicate = mcpServers.findIndex((e) => e.name === name);
if (duplicate !== -1 && duplicate !== editingMcpIndex) {
setMcpLocalError(`Server "${name}" already exists`);
return;
}
const entry: McpServerEntry = { name, configJson: mcpJson.trim(), error: null };
if (editingMcpIndex !== null) {
const updated = [...mcpServers];
updated[editingMcpIndex] = entry;
onMcpServersChange(updated);
} else {
onMcpServersChange([...mcpServers, entry]);
}
cancelMcpEdit();
};
const removeMcp = (index: number) => {
onMcpServersChange(mcpServers.filter((_, i) => i !== index));
if (editingMcpIndex === index) {
cancelMcpEdit();
}
};
const isEditingMcp = addingMcp || editingMcpIndex !== null;
if (phase === "agent") {
return (
<div className="session-create-menu">
@ -378,30 +163,25 @@ const SessionCreateMenu = ({
);
}
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
if (phase === "loading-config") {
return (
<div className="session-create-menu">
<div className="session-create-header">
<button className="session-create-back" onClick={handleBack} title="Back to agents">
<ArrowLeft size={14} />
</button>
<span className="session-create-agent-name">{agentLabel}</span>
</div>
<div className="sidebar-add-status">Loading config...</div>
</div>
);
}
// Phase 2: config form
const activeModes = modesByAgent[selectedAgent] ?? [];
const modesLoading = modesLoadingByAgent[selectedAgent] ?? false;
const modesError = modesErrorByAgent[selectedAgent] ?? null;
const modelOptions = modelsByAgent[selectedAgent] ?? [];
const modelsLoading = modelsLoadingByAgent[selectedAgent] ?? false;
const modelsError = modelsErrorByAgent[selectedAgent] ?? null;
const defaultModel = defaultModelByAgent[selectedAgent] ?? "";
const selectedModelId = model || defaultModel;
const selectedModelObj = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModelObj?.variants ?? [];
const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0;
const hasModelOptions = modelOptions.length > 0;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const supportsVariants =
modelsLoading ||
Boolean(modelsError) ||
modelOptions.some((entry) => (entry.variants?.length ?? 0) > 0);
const showVariantSelect =
supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0);
const hasVariantOptions = variantOptions.length > 0;
const variantCustom = variant && hasVariantOptions && !variantOptions.includes(variant);
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
const activeModels = modelsByAgent[selectedAgent] ?? [];
return (
<div className="session-create-menu">
@ -415,330 +195,69 @@ const SessionCreateMenu = ({
<div className="session-create-form">
<div className="setup-field">
<span className="setup-label">Model</span>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => { setModel(e.target.value); setVariant(""); }}
title="Model"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
{isCustomModel ? (
<input
className="setup-input"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Model"
title="Model"
type="text"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder="Enter model name..."
autoFocus
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
title="Mode"
disabled={modesLoading || Boolean(modesError)}
>
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((m) => (
) : (
<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}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
))}
<option value={CUSTOM_MODEL_VALUE}>Custom...</option>
</select>
)}
{isCustomModel && (
<button
className="setup-custom-back"
onClick={() => {
setIsCustomModel(false);
setCustomModel("");
const defaultModel = defaultModelByAgent[selectedAgent];
setSelectedModel(
defaultModel || (activeModels.length > 0 ? activeModels[0].id : "")
);
}}
title="Back to model list"
type="button"
>
List
</button>
)}
</div>
<div className="setup-field">
<span className="setup-label">Permission</span>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value)}
title="Permission Mode"
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
</div>
{supportsVariants && (
{activeModes.length > 0 && (
<div className="setup-field">
<span className="setup-label">Variant</span>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => setVariant(e.target.value)}
title="Variant"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">Default</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => setVariant(e.target.value)}
placeholder="Variant"
title="Variant"
/>
)}
<span className="setup-label">Mode</span>
<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}
</option>
))}
</select>
</div>
)}
{/* MCP Servers - collapsible */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setMcpExpanded(!mcpExpanded)}
>
<span className="setup-label">MCP</span>
<span className="session-create-section-count">{mcpServers.length} server{mcpServers.length !== 1 ? "s" : ""}</span>
{mcpExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{mcpExpanded && (
<div className="session-create-section-body">
{mcpServers.length > 0 && !isEditingMcp && (
<div className="session-create-mcp-list">
{mcpServers.map((entry, index) => (
<div key={entry.name} className="session-create-mcp-item">
<div className="session-create-mcp-info">
<span className="session-create-mcp-name">{entry.name}</span>
{getServerType(entry.configJson) && (
<span className="session-create-mcp-type">{getServerType(entry.configJson)}</span>
)}
<span className="session-create-mcp-summary mono">{getServerSummary(entry.configJson)}</span>
</div>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditMcp(index)}
title="Edit server"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeMcp(index)}
title="Remove server"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingMcp ? (
<div className="session-create-mcp-edit">
<input
ref={mcpNameRef}
className="session-create-mcp-name-input"
value={mcpName}
onChange={(e) => { setMcpName(e.target.value); setMcpLocalError(null); }}
placeholder="server-name"
disabled={editingMcpIndex !== null}
/>
<textarea
ref={mcpJsonRef}
className="session-create-textarea mono"
value={mcpJson}
onChange={(e) => { setMcpJson(e.target.value); setMcpLocalError(null); }}
placeholder='{"type":"local","command":"node","args":["./server.js"]}'
rows={4}
/>
{mcpLocalError && (
<div className="session-create-inline-error">{mcpLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitMcp}>
{editingMcpIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelMcpEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddMcp}
>
<Plus size={12} />
Add server
</button>
)}
{mcpConfigError && !isEditingMcp && (
<div className="session-create-inline-error">{mcpConfigError}</div>
)}
</div>
)}
</div>
{/* Skills - collapsible with source-based list */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setSkillsExpanded(!skillsExpanded)}
>
<span className="setup-label">Skills</span>
<span className="session-create-section-count">{skillSources.length} source{skillSources.length !== 1 ? "s" : ""}</span>
{skillsExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{skillsExpanded && (
<div className="session-create-section-body">
{skillSources.length > 0 && !isEditingSkill && (
<div className="session-create-skill-list">
{skillSources.map((entry, index) => (
<div key={`${entry.type}-${entry.source}-${index}`} className="session-create-skill-item">
<span className="session-create-skill-type-badge">{entry.type}</span>
<span className="session-create-skill-path mono">{skillSourceSummary(entry)}</span>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditSkill(index)}
title="Edit source"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeSkill(index)}
title="Remove source"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingSkill ? (
<div className="session-create-mcp-edit">
<div className="session-create-skill-type-row">
<select
className="session-create-skill-type-select"
value={skillType}
onChange={(e) => { setSkillType(e.target.value as "github" | "local" | "git"); setSkillLocalError(null); }}
>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
</select>
<input
ref={skillSourceRef}
className="session-create-skill-input mono"
value={skillSource}
onChange={(e) => { setSkillSource(e.target.value); setSkillLocalError(null); }}
placeholder={skillType === "github" ? "owner/repo" : skillType === "local" ? "/path/to/skill" : "https://git.example.com/repo.git"}
/>
</div>
<input
className="session-create-skill-input mono"
value={skillFilter}
onChange={(e) => setSkillFilter(e.target.value)}
placeholder="Filter skills (comma-separated, optional)"
/>
{skillType !== "local" && (
<div className="session-create-skill-type-row">
<input
className="session-create-skill-input mono"
value={skillRef}
onChange={(e) => setSkillRef(e.target.value)}
placeholder="Branch/tag (optional)"
/>
<input
className="session-create-skill-input mono"
value={skillSubpath}
onChange={(e) => setSkillSubpath(e.target.value)}
placeholder="Subpath (optional)"
/>
</div>
)}
{skillLocalError && (
<div className="session-create-inline-error">{skillLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitSkill}>
{editingSkillIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelSkillEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddSkill}
>
<Plus size={12} />
Add source
</button>
)}
</div>
)}
</div>
</div>
<div className="session-create-actions">
<button
className="button primary"
onClick={handleCreate}
disabled={Boolean(mcpConfigError)}
>
<button className="button primary" onClick={handleCreate}>
Create Session
</button>
</div>

View file

@ -1,23 +1,22 @@
import { getAvatarLabel, getMessageClass } from "./messageUtils";
import renderContentPart from "./renderContentPart";
import type { TimelineEntry } from "./types";
import { formatJson } from "../../utils/format";
const ChatMessages = ({
entries,
sessionError,
eventError,
messagesEndRef
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
}) => {
return (
<div className="messages">
{entries.map((entry) => {
const messageClass = getMessageClass(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>
@ -31,53 +30,73 @@ const ChatMessages = ({
);
}
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 statusValue = item.status ?? "";
const statusLabel =
statusValue && statusValue !== "completed" ? statusValue.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") && (
if (entry.kind === "reasoning") {
return (
<div key={entry.id} className="message assistant">
<div className="avatar">AI</div>
<div className="message-content">
<div className="message-meta">
<span>{kindLabel}</span>
{statusLabel && (
<span className={`pill ${item.status === "failed" ? "danger" : "accent"}`}>
{statusLabel}
<span>reasoning - {entry.reasoning?.visibility ?? "public"}</span>
</div>
<div className="part-body muted">{entry.reasoning?.text ?? ""}</div>
</div>
</div>
);
}
if (entry.kind === "tool") {
const isComplete = entry.toolStatus === "completed" || entry.toolStatus === "failed";
const isFailed = entry.toolStatus === "failed";
return (
<div key={entry.id} className={`message tool ${isFailed ? "error" : ""}`}>
<div className="avatar">{getAvatarLabel(isFailed ? "error" : "tool")}</div>
<div className="message-content">
<div className="message-meta">
<span>tool call - {entry.toolName}</span>
{entry.toolStatus && entry.toolStatus !== "completed" && (
<span className={`pill ${isFailed ? "danger" : "accent"}`}>
{entry.toolStatus.replace("_", " ")}
</span>
)}
</div>
)}
{hasParts ? (
(item.content ?? []).map(renderContentPart)
) : entry.deltaText ? (
<span>
{entry.deltaText}
{isInProgress && <span className="cursor" />}
</span>
) : isInProgress ? (
{entry.toolInput && <pre className="code-block">{entry.toolInput}</pre>}
{isComplete && entry.toolOutput && (
<div className="part">
<div className="part-title">result</div>
<pre className="code-block">{entry.toolOutput}</pre>
</div>
)}
{!isComplete && !entry.toolInput && (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
</div>
</div>
);
}
// Message (user or assistant)
return (
<div key={entry.id} className={`message ${messageClass}`}>
<div className="avatar">{getAvatarLabel(messageClass)}</div>
<div className="message-content">
{entry.text ? (
<div className="part-body">{entry.text}</div>
) : (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
) : (
<span className="muted">No content yet.</span>
)}
</div>
</div>
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
{eventError && <div className="message-error">{eventError}</div>}
<div ref={messagesEndRef} />
</div>
);

View file

@ -1,15 +1,9 @@
import { MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { McpServerEntry } from "../../App";
import type {
AgentInfo,
AgentModelInfo,
AgentModeInfo,
PermissionEventData,
QuestionEventData,
SkillSource
} from "../../types/legacyApi";
import ApprovalsTab from "../debug/ApprovalsTab";
import type { AgentInfo } from "sandbox-agent";
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
@ -31,32 +25,11 @@ const ChatPanel = ({
messagesEndRef,
agentLabel,
currentAgentVersion,
sessionModel,
sessionVariant,
sessionPermissionMode,
sessionMcpServerCount,
sessionSkillSourceCount,
sessionEnded,
onEndSession,
eventError,
questionRequests,
permissionRequests,
questionSelections,
onSelectQuestionOption,
onAnswerQuestion,
onRejectQuestion,
onReplyPermission,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
@ -66,39 +39,18 @@ const ChatPanel = ({
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
onSelectAgent: (agentId: string) => Promise<void>;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentLabel: string;
currentAgentVersion?: string | null;
sessionModel?: string | null;
sessionVariant?: string | null;
sessionPermissionMode?: string | null;
sessionMcpServerCount: number;
sessionSkillSourceCount: number;
sessionEnded: boolean;
onEndSession: () => void;
eventError: string | null;
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;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
}) => {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@ -115,8 +67,6 @@ const ChatPanel = ({
return () => document.removeEventListener("mousedown", handler);
}, [showAgentMenu]);
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
return (
<div className="chat-panel">
<div className="panel-header">
@ -127,15 +77,22 @@ const ChatPanel = ({
</div>
<div className="panel-header-right">
{sessionId && (
<button
type="button"
className="button ghost small"
onClick={onEndSession}
title="End session"
>
<Square size={12} />
End
</button>
sessionEnded ? (
<span className="button ghost small" style={{ opacity: 0.5, cursor: "default" }} title="Session ended">
<CheckSquare size={12} />
Ended
</span>
) : (
<button
type="button"
className="button ghost small"
onClick={onEndSession}
title="End session"
>
<Square size={12} />
End
</button>
)
)}
</div>
</div>
@ -161,17 +118,8 @@ const ChatPanel = ({
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={onMcpServersChange}
mcpConfigError={mcpConfigError}
skillSources={skillSources}
onSkillSourcesChange={onSkillSourcesChange}
onSelectAgent={onSelectAgent}
onCreateSession={onCreateSession}
onSelectAgent={onSelectAgent}
open={showAgentMenu}
onClose={() => setShowAgentMenu(false)}
/>
@ -187,27 +135,11 @@ const ChatPanel = ({
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
eventError={eventError}
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}
@ -223,26 +155,12 @@ const ChatPanel = ({
<span className="session-config-label">Agent</span>
<span className="session-config-value">{agentLabel}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Model</span>
<span className="session-config-value">{sessionModel || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Variant</span>
<span className="session-config-value">{sessionVariant || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Permission</span>
<span className="session-config-value">{sessionPermissionMode || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">MCP Servers</span>
<span className="session-config-value">{sessionMcpServerCount}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Skills</span>
<span className="session-config-value">{sessionSkillSourceCount}</span>
</div>
{currentAgentVersion && (
<div className="session-config-field">
<span className="session-config-label">Version</span>
<span className="session-config-value">{currentAgentVersion}</span>
</div>
)}
</div>
)}
</div>

View file

@ -1,11 +1,10 @@
import type { UniversalItem } from "../../types/legacyApi";
import type { TimelineEntry } from "./types";
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";
export const getMessageClass = (entry: TimelineEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.role === "user") return "user";
return "assistant";
};

View file

@ -1,93 +0,0 @@
import type { ContentPart } from "../../types/legacyApi";
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

@ -1,14 +1,17 @@
import type { UniversalItem } from "../../types/legacyApi";
export type TimelineEntry = {
id: string;
kind: "item" | "meta";
kind: "message" | "tool" | "meta" | "reasoning";
time: string;
item?: UniversalItem;
deltaText?: string;
meta?: {
title: string;
detail?: string;
severity?: "info" | "error";
};
// For messages:
role?: "user" | "assistant";
text?: string;
// For tool calls:
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
// For reasoning:
reasoning?: { text: string; visibility?: string };
// For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" };
};

View file

@ -1,6 +1,8 @@
import { Download, Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import type { AgentInfo, AgentModeInfo } from "../../types/legacyApi";
import type { AgentInfo } from "sandbox-agent";
type AgentModeInfo = { id: string; name: string; description: string };
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents";
@ -52,9 +54,9 @@ const AgentsTab = ({
id,
installed: false,
credentialsAvailable: false,
version: undefined,
path: undefined,
capabilities: emptyFeatureCoverage
version: undefined as string | undefined,
path: undefined as string | undefined,
capabilities: emptyFeatureCoverage as AgentInfo["capabilities"],
}))).map((agent) => {
const isInstalling = installingAgent === agent.id;
return (

View file

@ -1,105 +0,0 @@
import { HelpCircle, Shield } from "lucide-react";
import type { PermissionEventData, QuestionEventData } from "../../types/legacyApi";
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

@ -1,19 +1,21 @@
import { Cloud, PlayCircle, Terminal } from "lucide-react";
import type { AgentInfo, AgentModeInfo, UniversalEvent } from "../../types/legacyApi";
import { Cloud, PlayCircle, Server, Terminal, Wrench } from "lucide-react";
import type { AgentInfo, SandboxAgent, SessionEvent } from "sandbox-agent";
type AgentModeInfo = { id: string; name: string; description: string };
import AgentsTab from "./AgentsTab";
import EventsTab from "./EventsTab";
import McpTab from "./McpTab";
import SkillsTab from "./SkillsTab";
import RequestLogTab from "./RequestLogTab";
import type { RequestLog } from "../../types/requestLog";
export type DebugTab = "log" | "events" | "agents";
export type DebugTab = "log" | "events" | "agents" | "mcp" | "skills";
const DebugPanel = ({
debugTab,
onDebugTabChange,
events,
offset,
onResetEvents,
eventsError,
requestLog,
copiedLogId,
onClearRequestLog,
@ -24,14 +26,13 @@ const DebugPanel = ({
onRefreshAgents,
onInstallAgent,
agentsLoading,
agentsError
agentsError,
getClient,
}: {
debugTab: DebugTab;
onDebugTabChange: (tab: DebugTab) => void;
events: UniversalEvent[];
offset: number;
events: SessionEvent[];
onResetEvents: () => void;
eventsError: string | null;
requestLog: RequestLog[];
copiedLogId: number | null;
onClearRequestLog: () => void;
@ -43,6 +44,7 @@ const DebugPanel = ({
onInstallAgent: (agentId: string, reinstall: boolean) => Promise<void>;
agentsLoading: boolean;
agentsError: string | null;
getClient: () => SandboxAgent;
}) => {
return (
<div className="debug-panel">
@ -60,6 +62,14 @@ const DebugPanel = ({
<Cloud className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Agents
</button>
<button className={`debug-tab ${debugTab === "mcp" ? "active" : ""}`} onClick={() => onDebugTabChange("mcp")}>
<Server className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
MCP
</button>
<button className={`debug-tab ${debugTab === "skills" ? "active" : ""}`} onClick={() => onDebugTabChange("skills")}>
<Wrench className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
Skills
</button>
</div>
<div className="debug-content">
@ -75,9 +85,7 @@ const DebugPanel = ({
{debugTab === "events" && (
<EventsTab
events={events}
offset={offset}
onClear={onResetEvents}
error={eventsError}
/>
)}
@ -92,6 +100,14 @@ const DebugPanel = ({
error={agentsError}
/>
)}
{debugTab === "mcp" && (
<McpTab getClient={getClient} />
)}
{debugTab === "skills" && (
<SkillsTab getClient={getClient} />
)}
</div>
</div>
);

View file

@ -1,19 +1,119 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import {
Ban,
Bot,
Brain,
ChevronDown,
ChevronRight,
Circle,
CircleX,
Command,
CornerDownLeft,
FilePen,
FileText,
FolderOpen,
Hourglass,
KeyRound,
ListChecks,
MessageSquare,
Plug,
Radio,
ScrollText,
Settings,
ShieldCheck,
SquarePlus,
SquareTerminal,
ToggleLeft,
Trash2,
Unplug,
Wrench,
type LucideIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { UniversalEvent } from "../../types/legacyApi";
import type { SessionEvent } from "sandbox-agent";
import { formatJson, formatTime } from "../../utils/format";
import { getEventCategory, getEventClass, getEventIcon, getEventKey, getEventType } from "./eventUtils";
type EventIconInfo = { Icon: LucideIcon; category: string };
function getEventIcon(method: string, payload: Record<string, unknown>): EventIconInfo {
if (method === "session/update") {
const params = payload.params as Record<string, unknown> | undefined;
const update = params?.update as Record<string, unknown> | undefined;
const updateType = update?.sessionUpdate as string | undefined;
switch (updateType) {
case "user_message_chunk":
return { Icon: MessageSquare, category: "prompt" };
case "agent_message_chunk":
return { Icon: Bot, category: "update" };
case "agent_thought_chunk":
return { Icon: Brain, category: "update" };
case "tool_call":
case "tool_call_update":
return { Icon: Wrench, category: "tool" };
case "plan":
return { Icon: ListChecks, category: "config" };
case "available_commands_update":
return { Icon: Command, category: "config" };
case "current_mode_update":
return { Icon: ToggleLeft, category: "config" };
case "config_option_update":
return { Icon: Settings, category: "config" };
default:
return { Icon: Radio, category: "update" };
}
}
switch (method) {
case "initialize":
return { Icon: Plug, category: "connection" };
case "authenticate":
return { Icon: KeyRound, category: "connection" };
case "session/new":
return { Icon: SquarePlus, category: "session" };
case "session/load":
return { Icon: FolderOpen, category: "session" };
case "session/prompt":
return { Icon: MessageSquare, category: "prompt" };
case "session/cancel":
return { Icon: Ban, category: "cancel" };
case "session/set_mode":
return { Icon: ToggleLeft, category: "config" };
case "session/set_config_option":
return { Icon: Settings, category: "config" };
case "session/request_permission":
return { Icon: ShieldCheck, category: "permission" };
case "fs/read_text_file":
return { Icon: FileText, category: "filesystem" };
case "fs/write_text_file":
return { Icon: FilePen, category: "filesystem" };
case "terminal/create":
return { Icon: SquareTerminal, category: "terminal" };
case "terminal/kill":
return { Icon: CircleX, category: "terminal" };
case "terminal/output":
return { Icon: ScrollText, category: "terminal" };
case "terminal/release":
return { Icon: Trash2, category: "terminal" };
case "terminal/wait_for_exit":
return { Icon: Hourglass, category: "terminal" };
case "_sandboxagent/session/detach":
return { Icon: Unplug, category: "session" };
case "(response)":
return { Icon: CornerDownLeft, category: "response" };
default:
if (method.startsWith("_sandboxagent/")) {
return { Icon: Radio, category: "connection" };
}
return { Icon: Circle, category: "response" };
}
}
const EventsTab = ({
events,
offset,
onClear,
error
}: {
events: UniversalEvent[];
offset: number;
events: SessionEvent[];
onClear: () => void;
error: string | null;
}) => {
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
const [copied, setCopied] = useState(false);
@ -55,10 +155,15 @@ const EventsTab = ({
}
}, [events.length]);
const getMethod = (event: SessionEvent): string => {
const payload = event.payload as Record<string, unknown>;
return typeof payload.method === "string" ? payload.method : "(response)";
};
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Offset: {offset}</span>
<span className="card-meta">{events.length} events</span>
<div className="inline-row">
<button
type="button"
@ -75,26 +180,26 @@ const EventsTab = ({
</div>
</div>
{error && <div className="banner error">{error}</div>}
{events.length === 0 ? (
<div className="card-meta">
No events yet. Start streaming to receive events.
No events yet. Create a session and send a message.
</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 eventKey = event.id;
const isCollapsed = collapsedEvents[eventKey] ?? true;
const toggleCollapsed = () =>
setCollapsedEvents((prev) => ({
...prev,
[eventKey]: !(prev[eventKey] ?? true)
}));
const Icon = getEventIcon(type);
const method = getMethod(event);
const payload = event.payload as Record<string, unknown>;
const { Icon, category } = getEventIcon(method, payload);
const time = formatTime(new Date(event.createdAt).toISOString());
const senderClass = event.sender === "client" ? "client" : "agent";
return (
<div key={eventKey} className={`event-item ${isCollapsed ? "collapsed" : "expanded"}`}>
<button
@ -103,24 +208,26 @@ const EventsTab = ({
onClick={toggleCollapsed}
title={isCollapsed ? "Expand payload" : "Collapse payload"}
>
<span className={`event-icon ${eventClass}`}>
<span className={`event-icon ${category}`}>
<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>
<span className={`event-type ${category}`}>{method}</span>
<span className={`pill ${senderClass === "client" ? "accent" : "success"}`}>
{event.sender}
</span>
<span className="event-time">{time}</span>
</div>
<div className="event-id">
Event #{event.event_id || event.sequence} - seq {event.sequence} - {event.source}
{event.synthetic ? " (synthetic)" : ""}
{event.id}
</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>}
{!isCollapsed && <pre className="code-block event-payload">{formatJson(event.payload)}</pre>}
</div>
);
})}

View file

@ -0,0 +1,210 @@
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { SandboxAgent } from "sandbox-agent";
import { formatJson } from "../../utils/format";
type McpEntry = {
name: string;
config: Record<string, unknown>;
};
const McpTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const [directory, setDirectory] = useState("/");
const [entries, setEntries] = useState<McpEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add/edit form state
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editJson, setEditJson] = useState("");
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()) {
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([]);
} finally {
setLoading(false);
}
}, [getClient]);
useEffect(() => {
loadAll(directory);
}, [directory, loadAll]);
const startAdd = () => {
setEditing(true);
setEditName("");
setEditJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}');
setEditError(null);
};
const cancelEdit = () => {
setEditing(false);
setEditName("");
setEditJson("");
setEditError(null);
};
const save = async () => {
const name = editName.trim();
if (!name) {
setEditError("Name is required");
return;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(editJson.trim());
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
setEditError("Must be a JSON object");
return;
}
} catch {
setEditError("Invalid JSON");
return;
}
setSaving(true);
setEditError(null);
try {
await getClient().setMcpConfig(
{ directory, mcpName: name },
parsed as Parameters<SandboxAgent["setMcpConfig"]>[1],
);
cancelEdit();
await loadAll(directory);
} catch (err) {
setEditError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
const remove = async (name: string) => {
try {
await getClient().deleteMcpConfig({ directory, mcpName: name });
await loadAll(directory);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete");
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">MCP Server Configuration</span>
<div className="inline-row">
{!editing && (
<button className="button secondary small" onClick={startAdd}>
<Plus className="button-icon" style={{ width: 12, height: 12 }} />
Add
</button>
)}
</div>
</div>
<div className="inline-row" style={{ marginBottom: 12, gap: 6 }}>
<FolderOpen size={14} className="muted" style={{ flexShrink: 0 }} />
<input
className="setup-input mono"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/"
style={{ flex: 1, fontSize: 11 }}
/>
</div>
{error && <div className="banner error">{error}</div>}
{loading && <div className="card-meta">Loading...</div>}
{editing && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="card-header">
<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); }}
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); }}
rows={6}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11 }}
/>
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
</div>
<div className="card-actions">
<button className="button primary small" onClick={save} disabled={saving}>
{saving ? <Loader2 className="button-icon spinner-icon" /> : null}
Save
</button>
<button className="button ghost small" onClick={cancelEdit}>
Cancel
</button>
</div>
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No MCP servers configured in this directory.
</div>
)}
{entries.map((entry) => (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<span className="card-title">{entry.name}</span>
<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" }}
>
<Trash2 size={12} />
</button>
</div>
</div>
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
</div>
))}
</>
);
};
export default McpTab;

View file

@ -44,21 +44,21 @@ const RequestLogTab = ({
type="button"
onClick={() => hasDetails && toggleExpanded(entry.id)}
title={hasDetails ? (isExpanded ? "Collapse" : "Expand") : undefined}
style={{ cursor: hasDetails ? "pointer" : "default" }}
style={{ cursor: hasDetails ? "pointer" : "default", gridTemplateColumns: "1fr auto auto auto" }}
>
<div className="event-summary-main" style={{ flex: 1 }}>
<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-status ${entry.status && entry.status < 400 ? "ok" : "error"}`}>
{entry.status || "ERR"}
</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="copy-button"
onClick={(e) => {

View file

@ -0,0 +1,263 @@
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { SandboxAgent } from "sandbox-agent";
import { formatJson } from "../../utils/format";
type SkillEntry = {
name: string;
config: { sources: Array<{ source: string; type: string; ref?: string | null; subpath?: string | null; skills?: string[] | null }> };
};
const SkillsTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const [directory, setDirectory] = useState("/");
const [entries, setEntries] = useState<SkillEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add form state
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editSource, setEditSource] = useState("");
const [editType, setEditType] = useState("github");
const [editRef, setEditRef] = useState("");
const [editSubpath, setEditSubpath] = useState("");
const [editSkills, setEditSkills] = useState("");
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()) {
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([]);
} finally {
setLoading(false);
}
}, [getClient]);
useEffect(() => {
loadAll(directory);
}, [directory, loadAll]);
const startAdd = () => {
setEditing(true);
setEditName("");
setEditSource("rivet-dev/skills");
setEditType("github");
setEditRef("");
setEditSubpath("");
setEditSkills("sandbox-agent");
setEditError(null);
};
const cancelEdit = () => {
setEditing(false);
setEditName("");
setEditSource("");
setEditType("github");
setEditRef("");
setEditSubpath("");
setEditSkills("");
setEditError(null);
};
const save = async () => {
const name = editName.trim();
if (!name) {
setEditError("Name is required");
return;
}
const source = editSource.trim();
if (!source) {
setEditError("Source is required");
return;
}
const skillEntry: SkillEntry["config"]["sources"][0] = {
source,
type: editType,
};
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)
: null;
if (skillsList && skillsList.length > 0) skillEntry.skills = skillsList;
const config = { sources: [skillEntry] };
setSaving(true);
setEditError(null);
try {
await getClient().setSkillsConfig(
{ directory, skillName: name },
config,
);
cancelEdit();
await loadAll(directory);
} catch (err) {
setEditError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
const remove = async (name: string) => {
try {
await getClient().deleteSkillsConfig({ directory, skillName: name });
await loadAll(directory);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete");
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Skills Configuration</span>
<div className="inline-row">
{!editing && (
<button className="button secondary small" onClick={startAdd}>
<Plus className="button-icon" style={{ width: 12, height: 12 }} />
Add
</button>
)}
</div>
</div>
<div className="inline-row" style={{ marginBottom: 12, gap: 6 }}>
<FolderOpen size={14} className="muted" style={{ flexShrink: 0 }} />
<input
className="setup-input mono"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/"
style={{ flex: 1, fontSize: 11 }}
/>
</div>
{error && <div className="banner error">{error}</div>}
{loading && <div className="card-meta">Loading...</div>}
{editing && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="card-header">
<span className="card-title">Add Skill Source</span>
</div>
<div style={{ marginTop: 8 }}>
<input
className="setup-input"
value={editName}
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 }}
>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
</select>
<input
className="setup-input mono"
value={editSource}
onChange={(e) => { setEditSource(e.target.value); setEditError(null); }}
placeholder={editType === "github" ? "owner/repo" : editType === "local" ? "/path/to/skill" : "https://..."}
style={{ flex: 1 }}
/>
</div>
<input
className="setup-input"
value={editSkills}
onChange={(e) => setEditSkills(e.target.value)}
placeholder="Skills filter (comma-separated, optional)"
style={{ marginBottom: 6, width: "100%", boxSizing: "border-box" }}
/>
{editType !== "local" && (
<div className="inline-row" style={{ gap: 4 }}>
<input
className="setup-input mono"
value={editRef}
onChange={(e) => setEditRef(e.target.value)}
placeholder="Branch/tag (optional)"
style={{ flex: 1 }}
/>
<input
className="setup-input mono"
value={editSubpath}
onChange={(e) => setEditSubpath(e.target.value)}
placeholder="Subpath (optional)"
style={{ flex: 1 }}
/>
</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}>
{saving ? <Loader2 className="button-icon spinner-icon" /> : null}
Save
</button>
<button className="button ghost small" onClick={cancelEdit}>
Cancel
</button>
</div>
</div>
)}
{entries.length === 0 && !editing && !loading && (
<div className="card-meta">
No skills configured in this directory.
</div>
)}
{entries.map((entry) => (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<span className="card-title">{entry.name}</span>
<div className="card-header-pills">
<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" }}
>
<Trash2 size={12} />
</button>
</div>
</div>
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
</div>
))}
</>
);
};
export default SkillsTab;

View file

@ -1,110 +0,0 @@
import {
Activity,
AlertTriangle,
Brain,
CheckCircle,
FileDiff,
HelpCircle,
Info,
MessageSquare,
PauseCircle,
PlayCircle,
Shield,
Terminal,
Wrench,
Zap
} from "lucide-react";
import type { UniversalEvent } from "../../types/legacyApi";
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) {
// ACP session update events
case "acp.agent_message_chunk":
return MessageSquare;
case "acp.user_message_chunk":
return MessageSquare;
case "acp.agent_thought_chunk":
return Brain;
case "acp.tool_call":
return Wrench;
case "acp.tool_call_update":
return Activity;
case "acp.plan":
return FileDiff;
case "acp.session_info_update":
return Info;
case "acp.usage_update":
return Info;
case "acp.current_mode_update":
return Info;
case "acp.config_option_update":
return Info;
case "acp.available_commands_update":
return Terminal;
// Inspector lifecycle events
case "inspector.turn_started":
return PlayCircle;
case "inspector.turn_ended":
return PauseCircle;
case "inspector.user_message":
return MessageSquare;
// Session lifecycle (inspector-emitted)
case "session.started":
return PlayCircle;
case "session.ended":
return PauseCircle;
// Legacy synthetic events
case "turn.started":
return PlayCircle;
case "turn.ended":
return PauseCircle;
case "item.started":
return MessageSquare;
case "item.delta":
return Activity;
case "item.completed":
return CheckCircle;
// Approval events
case "question.requested":
return HelpCircle;
case "question.resolved":
return CheckCircle;
case "permission.requested":
return Shield;
case "permission.resolved":
return CheckCircle;
// Error events
case "error":
return AlertTriangle;
case "agent.unparsed":
return Brain;
default:
if (type.startsWith("acp.")) return Zap;
if (type.startsWith("inspector.")) return Info;
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

@ -1,790 +0,0 @@
import {
SandboxAgent,
type PermissionOption,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SandboxAgentAcpClient,
type SandboxAgentConnectOptions,
type SessionNotification,
} from "sandbox-agent";
import type {
AgentInfo,
AgentModelInfo,
AgentModeInfo,
AgentModelsResponse,
AgentModesResponse,
CreateSessionRequest,
EventsQuery,
EventsResponse,
MessageRequest,
PermissionEventData,
PermissionReplyRequest,
QuestionEventData,
QuestionReplyRequest,
SessionInfo,
SessionListResponse,
TurnStreamQuery,
UniversalEvent,
} from "../types/legacyApi";
type PendingPermission = {
request: RequestPermissionRequest;
resolve: (response: RequestPermissionResponse) => void;
autoEndTurnOnResolve?: boolean;
};
type PendingQuestion = {
prompt: string;
options: string[];
autoEndTurnOnResolve?: boolean;
};
type RuntimeSession = {
aliasSessionId: string;
realSessionId: string;
agent: string;
connection: SandboxAgentAcpClient;
events: UniversalEvent[];
nextSequence: number;
listeners: Set<(event: UniversalEvent) => void>;
info: SessionInfo;
pendingPermissions: Map<string, PendingPermission>;
pendingQuestions: Map<string, PendingQuestion>;
};
const TDOO_PERMISSION_MODE =
"TDOO: ACP permission mode preconfiguration is not implemented in inspector compatibility.";
const TDOO_VARIANT =
"TDOO: ACP session variants are not implemented in inspector compatibility.";
const TDOO_SKILLS =
"TDOO: ACP skills source configuration is not implemented in inspector compatibility.";
const TDOO_MODE_DISCOVERY =
"TDOO: ACP mode discovery before session creation is not implemented; returning cached/empty modes.";
const TDOO_MODEL_DISCOVERY =
"TDOO: ACP model discovery before session creation is not implemented; returning cached/empty models.";
export class InspectorLegacyClient {
private readonly base: SandboxAgent;
private readonly sessions = new Map<string, RuntimeSession>();
private readonly aliasByRealSessionId = new Map<string, string>();
private readonly modeCache = new Map<string, AgentModeInfo[]>();
private readonly modelCache = new Map<string, AgentModelsResponse>();
private permissionCounter = 0;
private constructor(base: SandboxAgent) {
this.base = base;
}
static async connect(options: SandboxAgentConnectOptions): Promise<InspectorLegacyClient> {
const base = await SandboxAgent.connect(options);
return new InspectorLegacyClient(base);
}
async getHealth() {
return this.base.getHealth();
}
async listAgents(): Promise<{ agents: AgentInfo[] }> {
const response = await this.base.listAgents();
return {
agents: response.agents.map((agent) => {
const installed =
agent.agent_process_installed &&
(!agent.native_required || agent.native_installed);
return {
id: agent.id,
installed,
credentialsAvailable: true,
version: agent.agent_process_version ?? agent.native_version ?? null,
path: null,
capabilities: {
unstable_methods: agent.capabilities.unstable_methods,
},
native_required: agent.native_required,
native_installed: agent.native_installed,
native_version: agent.native_version,
agent_process_installed: agent.agent_process_installed,
agent_process_source: agent.agent_process_source,
agent_process_version: agent.agent_process_version,
};
}),
};
}
async installAgent(agent: string, request: { reinstall?: boolean } = {}) {
return this.base.installAgent(agent, request);
}
async getAgentModes(agentId: string): Promise<AgentModesResponse> {
const modes = this.modeCache.get(agentId);
if (modes) {
return { modes };
}
console.warn(TDOO_MODE_DISCOVERY);
return { modes: [] };
}
async getAgentModels(agentId: string): Promise<AgentModelsResponse> {
const models = this.modelCache.get(agentId);
if (models) {
return models;
}
console.warn(TDOO_MODEL_DISCOVERY);
return { models: [], defaultModel: null };
}
async createSession(aliasSessionId: string, request: CreateSessionRequest): Promise<void> {
await this.terminateSession(aliasSessionId).catch(() => {
// Ignore if it doesn't exist yet.
});
const acp = await this.base.createAcpClient({
agent: request.agent,
client: {
sessionUpdate: async (notification) => {
this.handleSessionUpdate(notification);
},
requestPermission: async (permissionRequest) => {
return this.handlePermissionRequest(permissionRequest);
},
},
});
await acp.initialize();
const created = await acp.newSession({
cwd: "/",
mcpServers: convertMcpConfig(request.mcp ?? {}),
});
if (created.modes?.availableModes) {
this.modeCache.set(
request.agent,
created.modes.availableModes.map((mode) => ({
id: mode.id,
name: mode.name,
description: mode.description ?? undefined,
})),
);
}
if (created.models?.availableModels) {
this.modelCache.set(request.agent, {
models: created.models.availableModels.map((model) => ({
id: model.modelId,
name: model.name,
description: model.description ?? undefined,
})),
defaultModel: created.models.currentModelId ?? null,
});
}
const runtime: RuntimeSession = {
aliasSessionId,
realSessionId: created.sessionId,
agent: request.agent,
connection: acp,
events: [],
nextSequence: 1,
listeners: new Set(),
info: {
sessionId: aliasSessionId,
agent: request.agent,
eventCount: 0,
ended: false,
model: request.model ?? null,
variant: request.variant ?? null,
permissionMode: request.permissionMode ?? null,
mcp: request.mcp,
skills: request.skills,
},
pendingPermissions: new Map(),
pendingQuestions: new Map(),
};
this.sessions.set(aliasSessionId, runtime);
this.aliasByRealSessionId.set(created.sessionId, aliasSessionId);
if (request.agentMode) {
try {
await acp.setSessionMode({ sessionId: created.sessionId, modeId: request.agentMode });
} catch {
this.emitError(aliasSessionId, `TDOO: Unable to apply mode \"${request.agentMode}\" via ACP.`);
}
}
if (request.model) {
try {
await acp.unstableSetSessionModel({
sessionId: created.sessionId,
modelId: request.model,
});
} catch {
this.emitError(aliasSessionId, `TDOO: Unable to apply model \"${request.model}\" via ACP.`);
}
}
if (request.permissionMode) {
this.emitError(aliasSessionId, TDOO_PERMISSION_MODE);
}
if (request.variant) {
this.emitError(aliasSessionId, TDOO_VARIANT);
}
if (request.skills?.sources && request.skills.sources.length > 0) {
this.emitError(aliasSessionId, TDOO_SKILLS);
}
this.emitEvent(aliasSessionId, "session.started", {
session_id: aliasSessionId,
agent: request.agent,
});
}
async listSessions(): Promise<SessionListResponse> {
const sessions = Array.from(this.sessions.values()).map((session) => {
return {
...session.info,
eventCount: session.events.length,
};
});
return { sessions };
}
async postMessage(sessionId: string, request: MessageRequest): Promise<void> {
const runtime = this.requireActiveSession(sessionId);
const message = request.message.trim();
if (!message) {
return;
}
this.emitEvent(sessionId, "inspector.turn_started", {
session_id: sessionId,
});
this.emitEvent(sessionId, "inspector.user_message", {
session_id: sessionId,
text: message,
});
try {
await runtime.connection.prompt({
sessionId: runtime.realSessionId,
prompt: [{ type: "text", text: message }],
});
} catch (error) {
const detail = error instanceof Error ? error.message : "prompt failed";
this.emitError(sessionId, detail);
throw error;
} finally {
this.emitEvent(sessionId, "inspector.turn_ended", {
session_id: sessionId,
});
}
}
async getEvents(sessionId: string, query: EventsQuery = {}): Promise<EventsResponse> {
const runtime = this.requireSession(sessionId);
const offset = query.offset ?? 0;
const limit = query.limit ?? 200;
const events = runtime.events.filter((event) => event.sequence > offset).slice(0, limit);
return { events };
}
async *streamEvents(
sessionId: string,
query: EventsQuery = {},
signal?: AbortSignal,
): AsyncIterable<UniversalEvent> {
const runtime = this.requireSession(sessionId);
let cursor = query.offset ?? 0;
for (const event of runtime.events) {
if (event.sequence <= cursor) {
continue;
}
cursor = event.sequence;
yield event;
}
const queue: UniversalEvent[] = [];
let wake: (() => void) | null = null;
const listener = (event: UniversalEvent) => {
if (event.sequence <= cursor) {
return;
}
queue.push(event);
if (wake) {
wake();
wake = null;
}
};
runtime.listeners.add(listener);
try {
while (!signal?.aborted) {
if (queue.length === 0) {
await waitForSignalOrEvent(signal, () => {
wake = () => {};
return new Promise<void>((resolve) => {
wake = resolve;
});
});
continue;
}
const next = queue.shift();
if (!next) {
continue;
}
cursor = next.sequence;
yield next;
}
} finally {
runtime.listeners.delete(listener);
}
}
async *streamTurn(
sessionId: string,
request: MessageRequest,
_query?: TurnStreamQuery,
signal?: AbortSignal,
): AsyncIterable<UniversalEvent> {
if (signal?.aborted) {
return;
}
const runtime = this.requireActiveSession(sessionId);
let cursor = runtime.nextSequence - 1;
const queue: UniversalEvent[] = [];
let wake: (() => void) | null = null;
let promptDone = false;
let promptError: unknown = null;
const notify = () => {
if (wake) {
wake();
wake = null;
}
};
const listener = (event: UniversalEvent) => {
if (event.sequence <= cursor) {
return;
}
queue.push(event);
notify();
};
runtime.listeners.add(listener);
const promptPromise = this.postMessage(sessionId, request)
.catch((error) => {
promptError = error;
})
.finally(() => {
promptDone = true;
notify();
});
try {
while (!signal?.aborted) {
if (queue.length === 0) {
if (promptDone) {
break;
}
await waitForSignalOrEvent(signal, () => {
wake = () => {};
return new Promise<void>((resolve) => {
wake = resolve;
});
});
continue;
}
const next = queue.shift();
if (!next) {
continue;
}
cursor = next.sequence;
yield next;
}
} finally {
runtime.listeners.delete(listener);
}
await promptPromise;
if (promptError) {
throw promptError;
}
}
async replyQuestion(
sessionId: string,
questionId: string,
request: QuestionReplyRequest,
): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingQuestions.get(questionId);
if (!pending) {
throw new Error("TDOO: Question request no longer pending.");
}
runtime.pendingQuestions.delete(questionId);
const response = request.answers?.[0]?.[0] ?? null;
const resolved: QuestionEventData & { response?: string | null } = {
question_id: questionId,
status: "resolved",
prompt: pending.prompt,
options: pending.options,
response,
};
this.emitEvent(sessionId, "question.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async rejectQuestion(sessionId: string, questionId: string): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingQuestions.get(questionId);
if (!pending) {
throw new Error("TDOO: Question request no longer pending.");
}
runtime.pendingQuestions.delete(questionId);
const resolved: QuestionEventData & { response?: string | null } = {
question_id: questionId,
status: "resolved",
prompt: pending.prompt,
options: pending.options,
response: null,
};
this.emitEvent(sessionId, "question.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async replyPermission(
sessionId: string,
permissionId: string,
request: PermissionReplyRequest,
): Promise<void> {
const runtime = this.requireSession(sessionId);
const pending = runtime.pendingPermissions.get(permissionId);
if (!pending) {
throw new Error("TDOO: Permission request no longer pending.");
}
const optionId = selectPermissionOption(pending.request.options, request.reply);
const response: RequestPermissionResponse = optionId
? {
outcome: {
outcome: "selected",
optionId,
},
}
: {
outcome: {
outcome: "cancelled",
},
};
pending.resolve(response);
runtime.pendingPermissions.delete(permissionId);
const action = pending.request.toolCall.title ?? pending.request.toolCall.kind ?? "permission";
const resolved: PermissionEventData = {
permission_id: permissionId,
status: "resolved",
action,
metadata: {
reply: request.reply,
},
};
this.emitEvent(sessionId, "permission.resolved", resolved);
if (pending.autoEndTurnOnResolve) {
this.emitEvent(sessionId, "turn.ended", { session_id: sessionId });
}
}
async terminateSession(sessionId: string): Promise<void> {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
return;
}
this.emitEvent(sessionId, "session.ended", {
reason: "terminated_by_user",
terminated_by: "inspector",
});
runtime.info.ended = true;
for (const pending of runtime.pendingPermissions.values()) {
pending.resolve({
outcome: {
outcome: "cancelled",
},
});
}
runtime.pendingPermissions.clear();
runtime.pendingQuestions.clear();
try {
await runtime.connection.close();
} catch {
// Best-effort close.
}
this.aliasByRealSessionId.delete(runtime.realSessionId);
}
async dispose(): Promise<void> {
for (const sessionId of Array.from(this.sessions.keys())) {
await this.terminateSession(sessionId);
}
await this.base.dispose();
}
private handleSessionUpdate(notification: SessionNotification): void {
const aliasSessionId = this.aliasByRealSessionId.get(notification.sessionId);
if (!aliasSessionId) {
return;
}
const runtime = this.sessions.get(aliasSessionId);
if (!runtime || runtime.info.ended) {
return;
}
const update = notification.update;
// Still handle session_info_update for sidebar metadata
if (update.sessionUpdate === "session_info_update") {
runtime.info.title = update.title ?? runtime.info.title;
runtime.info.updatedAt = update.updatedAt ?? runtime.info.updatedAt;
}
// Emit the raw notification as the event data, using the ACP discriminator as the type
this.emitEvent(aliasSessionId, `acp.${update.sessionUpdate}`, notification);
}
private async handlePermissionRequest(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const aliasSessionId = this.aliasByRealSessionId.get(request.sessionId);
if (!aliasSessionId) {
return {
outcome: {
outcome: "cancelled",
},
};
}
const runtime = this.sessions.get(aliasSessionId);
if (!runtime || runtime.info.ended) {
return {
outcome: {
outcome: "cancelled",
},
};
}
this.permissionCounter += 1;
const permissionId = `permission-${this.permissionCounter}`;
const action = request.toolCall.title ?? request.toolCall.kind ?? "permission";
const pendingEvent: PermissionEventData = {
permission_id: permissionId,
status: "requested",
action,
metadata: request,
};
this.emitEvent(aliasSessionId, "permission.requested", pendingEvent);
return await new Promise<RequestPermissionResponse>((resolve) => {
runtime.pendingPermissions.set(permissionId, { request, resolve });
});
}
private emitError(sessionId: string, message: string): void {
this.emitEvent(sessionId, "error", {
message,
});
}
private emitEvent(sessionId: string, type: string, data: unknown): void {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
return;
}
const event: UniversalEvent = {
event_id: `${sessionId}-${runtime.nextSequence}`,
sequence: runtime.nextSequence,
type,
source: "inspector.acp",
time: new Date().toISOString(),
synthetic: true,
data,
};
runtime.nextSequence += 1;
runtime.events.push(event);
runtime.info.eventCount = runtime.events.length;
for (const listener of runtime.listeners) {
listener(event);
}
}
private requireSession(sessionId: string): RuntimeSession {
const runtime = this.sessions.get(sessionId);
if (!runtime) {
throw new Error(`Session not found: ${sessionId}`);
}
return runtime;
}
private requireActiveSession(sessionId: string): RuntimeSession {
const runtime = this.requireSession(sessionId);
if (runtime.info.ended) {
throw new Error(`Session ended: ${sessionId}`);
}
return runtime;
}
}
const convertMcpConfig = (mcp: Record<string, unknown>) => {
return Object.entries(mcp)
.map(([name, config]) => {
if (!config || typeof config !== "object") {
return null;
}
const value = config as Record<string, unknown>;
const type = value.type;
if (type === "local") {
const commandValue = value.command;
const argsValue = value.args;
let command = "";
let args: string[] = [];
if (Array.isArray(commandValue) && commandValue.length > 0) {
command = String(commandValue[0] ?? "");
args = commandValue.slice(1).map((part) => String(part));
} else if (typeof commandValue === "string") {
command = commandValue;
}
if (Array.isArray(argsValue)) {
args = argsValue.map((part) => String(part));
}
const envObject =
value.env && typeof value.env === "object" ? (value.env as Record<string, unknown>) : {};
const env = Object.entries(envObject).map(([envName, envValue]) => ({
name: envName,
value: String(envValue),
}));
return {
name,
command,
args,
env,
};
}
if (type === "remote") {
const headersObject =
value.headers && typeof value.headers === "object"
? (value.headers as Record<string, unknown>)
: {};
const headers = Object.entries(headersObject).map(([headerName, headerValue]) => ({
name: headerName,
value: String(headerValue),
}));
return {
type: "http" as const,
name,
url: String(value.url ?? ""),
headers,
};
}
return null;
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
};
const selectPermissionOption = (
options: PermissionOption[],
reply: PermissionReplyRequest["reply"],
): string | null => {
const pick = (...kinds: PermissionOption["kind"][]) => {
return options.find((option) => kinds.includes(option.kind))?.optionId ?? null;
};
if (reply === "always") {
return pick("allow_always", "allow_once");
}
if (reply === "once") {
return pick("allow_once", "allow_always");
}
return pick("reject_once", "reject_always");
};
const waitForSignalOrEvent = async (
signal: AbortSignal | undefined,
createWaitPromise: () => Promise<void>,
) => {
if (signal?.aborted) {
return;
}
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) {
return;
}
done = true;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
};
const onAbort = () => finish();
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
createWaitPromise().then(finish).catch(finish);
});
};

View file

@ -1,145 +0,0 @@
export type SkillSourceType = "github" | "local" | "git";
export type SkillSource = {
type: SkillSourceType;
source: string;
skills?: string[];
ref?: string;
subpath?: string;
};
export type CreateSessionRequest = {
agent: string;
agentMode?: string;
permissionMode?: string;
model?: string;
variant?: string;
mcp?: Record<string, unknown>;
skills?: {
sources: SkillSource[];
};
};
export type AgentModeInfo = {
id: string;
name?: string;
description?: string;
};
export type AgentModelInfo = {
id: string;
name?: string;
description?: string;
variants?: string[];
};
export type AgentInfo = {
id: string;
installed: boolean;
credentialsAvailable: boolean;
version?: string | null;
path?: string | null;
capabilities: Record<string, boolean | undefined>;
native_required?: boolean;
native_installed?: boolean;
native_version?: string | null;
agent_process_installed?: boolean;
agent_process_source?: string | null;
agent_process_version?: string | null;
};
export type ContentPart = {
type?: string;
[key: string]: unknown;
};
export type UniversalItem = {
item_id: string;
native_item_id?: string | null;
parent_id?: string | null;
kind: string;
role?: string | null;
content?: ContentPart[];
status?: string | null;
[key: string]: unknown;
};
export type UniversalEvent = {
event_id: string;
sequence: number;
type: string;
source: string;
time: string;
synthetic?: boolean;
data: unknown;
[key: string]: unknown;
};
export type PermissionEventData = {
permission_id: string;
status: "requested" | "resolved";
action: string;
metadata?: unknown;
};
export type QuestionEventData = {
question_id: string;
status: "requested" | "resolved";
prompt: string;
options: string[];
};
export type SessionInfo = {
sessionId: string;
agent: string;
eventCount: number;
ended?: boolean;
model?: string | null;
variant?: string | null;
permissionMode?: string | null;
mcp?: Record<string, unknown>;
skills?: {
sources?: SkillSource[];
};
title?: string | null;
updatedAt?: string | null;
};
export type EventsQuery = {
offset?: number;
limit?: number;
includeRaw?: boolean;
};
export type EventsResponse = {
events: UniversalEvent[];
};
export type SessionListResponse = {
sessions: SessionInfo[];
};
export type AgentModesResponse = {
modes: AgentModeInfo[];
};
export type AgentModelsResponse = {
models: AgentModelInfo[];
defaultModel?: string | null;
};
export type MessageRequest = {
message: string;
};
export type TurnStreamQuery = {
includeRaw?: boolean;
};
export type PermissionReplyRequest = {
reply: "once" | "always" | "reject";
};
export type QuestionReplyRequest = {
answers: string[][];
};