- Give your harness an isolated sandbox environment for code execution,
+ Choose the default sandbox this harness should use for code execution,
file management, terminal commands, and git operations.
@@ -1002,10 +1095,10 @@ function StepSandbox({
>
- Enable sandbox
+ Enable default sandbox
- A sandbox will be auto-provisioned when you start chatting
+ Attach an existing sandbox to this harness
@@ -1019,110 +1112,53 @@ function StepSandbox({
exit={{ opacity: 0, height: 0 }}
className="space-y-4"
>
- {/* Sandbox type */}
-
-
- Sandbox Type
+
+
+ Select Sandbox
-
-
-
-
-
-
- {/* Resource tier */}
-
-
- Resource Tier
-
-
-
-
- {/* Default language */}
-
-
- Default Language
-
-
+
+
+
+
+ {sandboxes.map((sandbox) => (
+
+ {sandbox.name}
+
+ {formatSandboxMeta(sandbox)}
+
+
+ ))}
+
+
+ ) : (
+
+
+ No existing sandboxes
+
+
+ Create a sandbox from the Sandboxes page, then return here to
+ attach it.
+
+
+ )}
)}
{!enabled && (
- You can enable a sandbox later from the harness settings.
+ You can set a default sandbox later from the harness settings.
)}
diff --git a/apps/web/src/routes/sandboxes/$sandboxId.tsx b/apps/web/src/routes/sandboxes/$sandboxId.tsx
new file mode 100644
index 0000000..103480c
--- /dev/null
+++ b/apps/web/src/routes/sandboxes/$sandboxId.tsx
@@ -0,0 +1,423 @@
+import { useAuth } from "@clerk/tanstack-react-start";
+import { convexQuery, useConvexMutation } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import type {
+ Doc,
+ Id,
+} from "@harness/convex-backend/convex/_generated/dataModel";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { createFileRoute, Link, redirect } from "@tanstack/react-router";
+import {
+ Archive,
+ ArrowLeft,
+ Calendar,
+ Check,
+ Code2,
+ Cpu,
+ Database,
+ Files,
+ GitBranch,
+ HardDrive,
+ Loader2,
+ MemoryStick,
+ Play,
+ RefreshCw,
+ Save,
+ Square,
+ Terminal,
+} from "lucide-react";
+import { motion } from "motion/react";
+import { useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { SandboxPanel } from "../../components/sandbox/sandbox-panel";
+import { Badge } from "../../components/ui/badge";
+import { Button } from "../../components/ui/button";
+import { Input } from "../../components/ui/input";
+import { Separator } from "../../components/ui/separator";
+import { Skeleton } from "../../components/ui/skeleton";
+import { createSandboxApi } from "../../lib/sandbox-api";
+import {
+ SandboxPanelProvider,
+ type SandboxTab,
+ useSandboxPanel,
+} from "../../lib/sandbox-panel-context";
+
+type Sandbox = Doc<"sandboxes">;
+
+export const Route = createFileRoute("/sandboxes/$sandboxId")({
+ beforeLoad: ({ context }) => {
+ if (!context.userId) {
+ throw redirect({ to: "/sign-in" });
+ }
+ },
+ component: SandboxDetailPage,
+});
+
+function SandboxDetailPage() {
+ const { sandboxId } = Route.useParams();
+ const { data: sandbox, isLoading } = useQuery(
+ convexQuery(api.sandboxes.get, {
+ id: sandboxId as Id<"sandboxes">,
+ }),
+ );
+
+ if (isLoading) {
+ return
;
+ }
+
+ if (!sandbox) {
+ return (
+
+
Sandbox not found.
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function SandboxDetailContent({ sandbox }: { sandbox: Sandbox }) {
+ const { getToken } = useAuth();
+ const panel = useSandboxPanel();
+ const [name, setName] = useState(sandbox.name);
+ const [workingDir, setWorkingDir] = useState("/home/daytona");
+
+ const sandboxApi = useMemo(() => createSandboxApi(getToken), [getToken]);
+ const updateSandboxFn = useConvexMutation(api.sandboxes.update);
+ const updateSandbox = useMutation({
+ mutationFn: updateSandboxFn,
+ onSuccess: () => toast.success("Sandbox saved"),
+ onError: () => toast.error("Failed to save sandbox"),
+ });
+ const updateSandboxStatus = useMutation({
+ mutationFn: updateSandboxFn,
+ });
+ const lifecycle = useMutation({
+ mutationFn: async (next: "start" | "stop") => {
+ if (next === "start") {
+ return sandboxApi.startSandbox(sandbox.daytonaSandboxId);
+ }
+ return sandboxApi.stopSandbox(sandbox.daytonaSandboxId);
+ },
+ onSuccess: (_, next) => {
+ updateSandboxStatus.mutate({
+ id: sandbox._id,
+ status: next === "start" ? "running" : "stopped",
+ });
+ toast.success(next === "start" ? "Sandbox started" : "Sandbox stopped");
+ },
+ onError: () => toast.error("Sandbox lifecycle action failed"),
+ });
+
+ const hasNameChanges = name.trim() !== "" && name !== sandbox.name;
+ const isRunning = sandbox.status === "running";
+
+ const openSandboxTool = (tab: SandboxTab) => {
+ panel?.setActiveTab(tab);
+ if (!panel?.panelOpen) panel?.togglePanel();
+ };
+
+ const navigateToWorkingDir = () => {
+ if (!workingDir.trim()) return;
+ panel?.navigateTo(workingDir.trim());
+ };
+
+ const saveMetadata = () => {
+ if (!hasNameChanges) return;
+ updateSandbox.mutate({ id: sandbox._id, name: name.trim() });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {name || sandbox.name}
+
+
+
+
+ {sandbox.daytonaSandboxId}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit Sandbox
+
+
+ Rename the sandbox here. Use the workspace tools to edit
+ files, create folders, run commands, inspect diffs, and commit
+ changes.
+
+
+
+
+
+
+
+ setName(e.target.value)}
+ placeholder="Sandbox name"
+ className="max-w-md"
+ />
+
+
+
+
+
+ setWorkingDir(e.target.value)}
+ placeholder="/home/daytona"
+ className="font-mono text-xs"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Supported Edits
+
+
+ These actions are supported directly because the backend
+ already exposes authenticated Daytona filesystem, command, and
+ git APIs.
+
+
+
+
+ openSandboxTool("files")}
+ />
+ openSandboxTool("terminal")}
+ />
+ openSandboxTool("git")}
+ />
+
+
+
+
+
+
+ {panel?.panelOpen &&
}
+
+ );
+}
+
+function SandboxFacts({ sandbox }: { sandbox: Sandbox }) {
+ return (
+
+
+
+
+
+
+
+ {sandbox.gitRepo && (
+
+ )}
+
+ );
+}
+
+function Fact({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: typeof Archive;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {label}
+ {value}
+
+ );
+}
+
+function EditCapability({
+ icon: Icon,
+ title,
+ description,
+ action,
+ onClick,
+}: {
+ icon: typeof Files;
+ title: string;
+ description: string;
+ action: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
{title}
+
+
+ {description}
+
+
+
+ );
+}
+
+function StatusBadge({ status }: { status: Sandbox["status"] }) {
+ const variant = status === "running" ? "default" : "secondary";
+ return (
+
+ {status === "running" && }
+ {status}
+
+ );
+}
+
+function DetailSkeleton() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/routes/sandboxes/create_sandbox.tsx b/apps/web/src/routes/sandboxes/create_sandbox.tsx
new file mode 100644
index 0000000..cdc0bcd
--- /dev/null
+++ b/apps/web/src/routes/sandboxes/create_sandbox.tsx
@@ -0,0 +1,268 @@
+import { useAuth } from "@clerk/tanstack-react-start";
+import {
+ createFileRoute,
+ Link,
+ redirect,
+ useNavigate,
+} from "@tanstack/react-router";
+import { ArrowLeft, Cpu, HardDrive, Loader2, Play } from "lucide-react";
+import { motion } from "motion/react";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { Button } from "../../components/ui/button";
+import { Input } from "../../components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../components/ui/select";
+import { env } from "../../env";
+
+const API_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000";
+
+export const Route = createFileRoute("/sandboxes/create_sandbox")({
+ beforeLoad: ({ context }) => {
+ if (!context.userId) {
+ throw redirect({ to: "/sign-in" });
+ }
+ },
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const { getToken } = useAuth();
+ const [name, setName] = useState("New sandbox");
+ const [isCreating, setIsCreating] = useState(false);
+ const [sandboxConfig, setSandboxConfig] = useState({
+ persistent: false,
+ autoStart: true,
+ defaultLanguage: "python",
+ resourceTier: "basic" as "basic" | "standard" | "performance",
+ });
+
+ const handleCreate = async () => {
+ setIsCreating(true);
+ try {
+ const token = await getToken();
+ const res = await fetch(`${API_URL}/api/sandbox`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ name: name.trim() || "New sandbox",
+ language: sandboxConfig.defaultLanguage,
+ resource_tier: sandboxConfig.resourceTier,
+ ephemeral: !sandboxConfig.persistent,
+ }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(text || `Sandbox API error ${res.status}`);
+ }
+
+ toast.success("Sandbox created");
+ navigate({ to: "/sandboxes" });
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to create sandbox",
+ );
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ setName(event.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StepSandbox({
+ config,
+ setConfig,
+}: {
+ config: {
+ persistent: boolean;
+ autoStart: boolean;
+ defaultLanguage: string;
+ resourceTier: "basic" | "standard" | "performance";
+ };
+ setConfig: (v: {
+ persistent: boolean;
+ autoStart: boolean;
+ defaultLanguage: string;
+ resourceTier: "basic" | "standard" | "performance";
+ }) => void;
+}) {
+ return (
+
+
+ Create a sandbox for code execution, file management, terminal commands,
+ and git operations.
+
+
+
+ {/* Sandbox type */}
+
+
+ Sandbox Type
+
+
+
+
+
+
+
+ {/* Resource tier */}
+
+
+ Resource Tier
+
+
+
+
+ {/* Default language */}
+
+
+ Default Language
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/sandboxes/index.tsx b/apps/web/src/routes/sandboxes/index.tsx
new file mode 100644
index 0000000..0be7312
--- /dev/null
+++ b/apps/web/src/routes/sandboxes/index.tsx
@@ -0,0 +1,556 @@
+import { convexQuery, useConvexMutation } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import type {
+ Doc,
+ Id,
+} from "@harness/convex-backend/convex/_generated/dataModel";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ createFileRoute,
+ Link,
+ redirect,
+ useNavigate,
+} from "@tanstack/react-router";
+import {
+ AlertTriangle,
+ Archive,
+ ArrowLeft,
+ Calendar,
+ Clock,
+ Code2,
+ Cpu,
+ Database,
+ Edit,
+ GitBranch,
+ HardDrive,
+ Hash,
+ MemoryStick,
+ MoreHorizontal,
+ Play,
+ Plus,
+ Square,
+ Trash2,
+} from "lucide-react";
+import { motion } from "motion/react";
+import type { ComponentType } from "react";
+import { useState } from "react";
+import { HarnessMark } from "../../components/harness-mark";
+import { Badge } from "../../components/ui/badge";
+import { Button } from "../../components/ui/button";
+import { Card, CardContent } from "../../components/ui/card";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../../components/ui/dropdown-menu";
+import { Skeleton } from "../../components/ui/skeleton";
+
+type Sandbox = Doc<"sandboxes">;
+type SandboxStatus = Sandbox["status"];
+
+export const Route = createFileRoute("/sandboxes/")({
+ beforeLoad: ({ context }) => {
+ if (!context.userId) {
+ throw redirect({ to: "/sign-in" });
+ }
+ },
+ component: SandboxesPage,
+});
+
+function SandboxesPage() {
+ const navigate = useNavigate();
+ const { data: sandboxes, isLoading } = useQuery(
+ convexQuery(api.sandboxes.list, {}),
+ );
+
+ const updateSandbox = useMutation({
+ mutationFn: useConvexMutation(api.sandboxes.update),
+ });
+ const removeSandbox = useMutation({
+ mutationFn: useConvexMutation(api.sandboxes.remove),
+ });
+ // const duplicateHarness = useMutation({
+ // mutationFn: useConvexMutation(api.harnesses.duplicate),
+ // })
+
+ const [deleteTarget, setDeleteTarget] = useState
| null>(
+ null,
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ // const handleDuplicate = (id: Id<"harnesses">) => {
+ // duplicateHarness.mutate(
+ // { id },
+ // { onSuccess: () => toast.success("Harness duplicated") },
+ // )
+ // }
+
+ const ephemeralSandboxes = sandboxes?.filter((s) => s.ephemeral) ?? [];
+ const persistentSandboxes = sandboxes?.filter((s) => !s.ephemeral) ?? [];
+
+ const handleToggleStatus = (id: Id<"sandboxes">, current: SandboxStatus) => {
+ const newStatus = current === "stopped" ? "running" : "stopped";
+ updateSandbox.mutate({ id, status: newStatus });
+ };
+
+ const handleDelete = () => {
+ if (deleteTarget) {
+ removeSandbox.mutate({ id: deleteTarget });
+ setDeleteTarget(null);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Your Sandboxes
+
+
+ {sandboxes?.length ?? 0} total
+
+
+
+
+
+
+
+ {sandboxes?.length === 0 ? (
+
+ ) : (
+
+ {persistentSandboxes.length > 0 && (
+
+ navigate({
+ to: "/sandboxes/$sandboxId",
+ params: { sandboxId: id },
+ })
+ }
+ />
+ )}
+ {ephemeralSandboxes.length > 0 && (
+
+ navigate({
+ to: "/sandboxes/$sandboxId",
+ params: { sandboxId: id },
+ })
+ }
+ />
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+function SandboxGroup({
+ title,
+ sandboxes,
+ onToggle,
+ onDelete,
+ // onDuplicate,
+ onEdit,
+}: {
+ title: string;
+ sandboxes: Array;
+ onToggle: (id: Id<"sandboxes">, status: SandboxStatus) => void;
+ onDelete: (id: Id<"sandboxes">) => void;
+ // onDuplicate: (id: Id<"sandboxes">) => void;
+ onEdit: (id: Id<"sandboxes">) => void;
+}) {
+ return (
+
+
+ {title}
+
+
+ {sandboxes.map((s, i) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function SandboxCard({
+ sandbox,
+ onToggle,
+ onDelete,
+ // onDuplicate,
+ onEdit,
+}: {
+ sandbox: Sandbox;
+ onToggle: (
+ id: Id<"sandboxes">,
+ status:
+ | "creating"
+ | "starting"
+ | "running"
+ | "stopping"
+ | "stopped"
+ | "archived"
+ | "error",
+ ) => void;
+ onDelete: (id: Id<"sandboxes">) => void;
+ // onDuplicate: (id: Id<"sandboxes">) => void;
+ onEdit: (id: Id<"sandboxes">) => void;
+}) {
+ const isDraft = false;
+ const statusMeta = getStatusMeta(sandbox.status);
+ const sandboxType = sandbox.ephemeral ? "Ephemeral" : "Persistent";
+ const language = formatLanguage(sandbox.language);
+ const createdAt = formatDate(sandbox.createdAt);
+ const lastAccessedAt = sandbox.lastAccessedAt
+ ? formatDate(sandbox.lastAccessedAt)
+ : "Never";
+ const shortDaytonaId = shortenId(sandbox.daytonaSandboxId);
+
+ return (
+
+
+
+
+
+
+
+
+ {statusMeta.label}
+
+
+
+ {sandboxType}
+
+
+
+
+
+
+
+
+ onEdit(sandbox._id)}>
+
+ Edit
+
+ {/* onDuplicate(sandbox._id)}>
+
+ Duplicate
+ */}
+ {!isDraft && (
+ onToggle(sandbox._id, sandbox.status)}
+ >
+ {sandbox.status === "running" ? (
+ <>
+
+ Stop
+ >
+ ) : (
+ <>
+
+ Start
+ >
+ )}
+
+ )}
+
+ onDelete(sandbox._id)}
+ >
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Created
+
+ {createdAt}
+
+
+
+
+ Last used
+
+ {lastAccessedAt}
+
+ {sandbox.gitRepo && (
+
+
+
+ Repo
+
+
+ {formatRepoName(sandbox.gitRepo)}
+
+
+ )}
+
+
+
+ Daytona ID
+
+
+ {shortDaytonaId}
+
+
+
+
+
+ );
+}
+
+function SandboxInfoItem({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: ComponentType<{ size?: number; className?: string }>;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ );
+}
+
+function getStatusMeta(status: SandboxStatus): {
+ label: string;
+ dotClass: string;
+ badgeVariant: "secondary" | "outline" | "destructive";
+ icon: ComponentType<{ size?: number }>;
+} {
+ switch (status) {
+ case "running":
+ return {
+ label: "Running",
+ dotClass: "bg-emerald-500",
+ badgeVariant: "secondary",
+ icon: Play,
+ };
+ case "stopped":
+ return {
+ label: "Stopped",
+ dotClass: "bg-muted-foreground/40",
+ badgeVariant: "outline",
+ icon: Square,
+ };
+ case "archived":
+ return {
+ label: "Archived",
+ dotClass: "bg-muted-foreground/40",
+ badgeVariant: "outline",
+ icon: Archive,
+ };
+ case "error":
+ return {
+ label: "Error",
+ dotClass: "bg-destructive",
+ badgeVariant: "destructive",
+ icon: AlertTriangle,
+ };
+ case "creating":
+ return {
+ label: "Creating",
+ dotClass: "bg-amber-400",
+ badgeVariant: "secondary",
+ icon: Database,
+ };
+ case "starting":
+ return {
+ label: "Starting",
+ dotClass: "bg-amber-400",
+ badgeVariant: "secondary",
+ icon: Play,
+ };
+ case "stopping":
+ return {
+ label: "Stopping",
+ dotClass: "bg-amber-400",
+ badgeVariant: "secondary",
+ icon: Square,
+ };
+ }
+}
+
+function formatLanguage(language?: string) {
+ if (!language) return "Not set";
+ return language.charAt(0).toUpperCase() + language.slice(1);
+}
+
+function formatDate(timestamp: number) {
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(new Date(timestamp));
+}
+
+function shortenId(id: string) {
+ if (id.length <= 12) return id;
+ return `${id.slice(0, 6)}...${id.slice(-4)}`;
+}
+
+function formatRepoName(repo: string) {
+ const trimmed = repo.replace(/\.git$/, "").replace(/\/$/, "");
+ const parts = trimmed.split("/");
+ return parts.slice(-2).join("/");
+}
+
+function EmptyState() {
+ return (
+
+
+
+
+
+ No sandboxes yet
+
+
+ Create your first sandbox to equip your AI agent with sandboxes to
+ execute code and a local filesystem.
+
+
+
+ );
+}
+
+function LoadingSkeleton() {
+ return (
+
+
+
+
+
+
+ {["sk1", "sk2", "sk3"].map((key) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx
index f81927e..0fb0ae2 100644
--- a/apps/web/src/routes/sign-in.tsx
+++ b/apps/web/src/routes/sign-in.tsx
@@ -64,7 +64,7 @@ function SignInPage() {