From ad3abd427b0a55a67fc503700b68c44235e07f20 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Fri, 6 Mar 2026 17:56:10 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20execution=20scaffold=20?= =?UTF-8?q?=E2=80=94=20sessions=20DB,=20API,=20and=20Browse/Agent/Terminal?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session model: - agent_sessions table (auto-created on first use): id, project_id, app_name, app_path, task, status, output (JSONB log), changed_files, error, timestamps - POST /agent/sessions — create session, fires off to agent-runner (gracefully degrades when runner not yet wired) - GET /agent/sessions — list sessions newest first - GET /agent/sessions/[id] — full session state for polling - PATCH /agent/sessions/[id] — internal: agent-runner appends output lines - POST /agent/sessions/[id]/stop — stop running session Build > Code section now has three mode tabs: - Browse — existing file tree + code viewer - Agent — task input, session list sidebar, live output stream, changed files panel, Approve & commit / Open in Theia actions, 2s polling (Phase 3 will replace with WebSocket) - Terminal — xterm.js placeholder (Phase 4) Architecture documented in AGENT_EXECUTION_ARCHITECTURE.md Made-with: Cursor --- .../project/[projectId]/build/page.tsx | 306 +++++++++++++++++- .../agent/sessions/[sessionId]/route.ts | 118 +++++++ .../agent/sessions/[sessionId]/stop/route.ts | 57 ++++ .../[projectId]/agent/sessions/route.ts | 170 ++++++++++ 4 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts create mode 100644 app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts create mode 100644 app/api/projects/[projectId]/agent/sessions/route.ts diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 9b672ca..3b87cb2 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -209,6 +209,299 @@ function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSel ); } +// ── Shared mode tab bar ─────────────────────────────────────────────────────── + +type CodeMode = "browse" | "agent" | "terminal"; + +function ModeTabs({ mode, onChange }: { mode: CodeMode; onChange: (m: CodeMode) => void }) { + const tabs: { id: CodeMode; label: string }[] = [ + { id: "browse", label: "Browse" }, + { id: "agent", label: "Agent" }, + { id: "terminal", label: "Terminal" }, + ]; + return ( +
+ {tabs.map(t => ( + + ))} +
+ ); +} + +// ── Agent mode ──────────────────────────────────────────────────────────────── + +interface AgentSession { + id: string; + app_name: string; + task: string; + status: "pending" | "running" | "done" | "failed" | "stopped"; + output: Array<{ ts: string; type: string; text: string }>; + changed_files: Array<{ path: string; status: string }>; + error: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; +} + +const STATUS_COLORS: Record = { + running: "#3d5afe", done: "#2e7d32", failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a", +}; +const STATUS_LABELS: Record = { + running: "Running", done: "Done", failed: "Failed", stopped: "Stopped", pending: "Starting…", +}; +const FILE_STATUS_COLORS: Record = { added: "#2e7d32", modified: "#d4a04a", deleted: "#c62828" }; + +function elapsed(start: string | null): string { + if (!start) return ""; + const s = Math.floor((Date.now() - new Date(start).getTime()) / 1000); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +function AgentMode({ projectId, appName, appPath }: { projectId: string; appName: string; appPath: string }) { + const [task, setTask] = useState(""); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [activeSession, setActiveSession] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [loadingSessions, setLoadingSessions] = useState(true); + const outputRef = useCallback((el: HTMLDivElement | null) => { + if (el) el.scrollTop = el.scrollHeight; + }, []); + + // Load session list + useEffect(() => { + if (!appName) return; + setLoadingSessions(true); + fetch(`/api/projects/${projectId}/agent/sessions`) + .then(r => r.json()) + .then(d => { setSessions(d.sessions ?? []); setLoadingSessions(false); }) + .catch(() => setLoadingSessions(false)); + }, [projectId, appName]); + + // Poll active session + useEffect(() => { + if (!activeSessionId) return; + const poll = async () => { + const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`); + const d = await r.json(); + if (d.session) setActiveSession(d.session); + }; + poll(); + const id = setInterval(() => { + poll(); + if (activeSession?.status && !["running", "pending"].includes(activeSession.status)) clearInterval(id); + }, 2000); + return () => clearInterval(id); + }, [activeSessionId, projectId, activeSession?.status]); + + const handleRun = async () => { + if (!task.trim() || !appName) return; + setSubmitting(true); + try { + const r = await fetch(`/api/projects/${projectId}/agent/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appName, appPath, task }), + }); + const d = await r.json(); + if (d.sessionId) { + setActiveSessionId(d.sessionId); + setTask(""); + // Refresh list + const list = await fetch(`/api/projects/${projectId}/agent/sessions`).then(r2 => r2.json()); + setSessions(list.sessions ?? []); + } + } finally { setSubmitting(false); } + }; + + const handleStop = async () => { + if (!activeSessionId) return; + await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/stop`, { method: "POST" }); + setActiveSession(prev => prev ? { ...prev, status: "stopped" } : null); + }; + + if (!appName) { + return ( +
+
Select an app first
+
Choose an app from the left nav to run agent tasks against it.
+
+ ); + } + + return ( +
+ {/* Session list sidebar */} +
+
+ Sessions +
+
+ {loadingSessions &&
Loading…
} + {!loadingSessions && sessions.length === 0 && ( +
No sessions yet. Run your first task below.
+ )} + {sessions.map(s => ( + + ))} +
+
+ + {/* Main panel */} +
+ {/* Active session view */} + {activeSession ? ( + <> + {/* Session header */} +
+ + {activeSession.task} + {activeSession.started_at && ( + + {["running", "pending"].includes(activeSession.status) ? `${elapsed(activeSession.started_at)} elapsed` : elapsed(activeSession.started_at)} + + )} + {["running", "pending"].includes(activeSession.status) && ( + + )} +
+ + {/* Output stream */} +
+ {activeSession.output.length === 0 && ( + Starting agent… + )} + {activeSession.output.map((line, i) => { + const color = line.type === "error" ? "#f87171" : line.type === "stderr" ? "#fb923c" + : line.type === "info" ? "#60a5fa" : line.type === "step" ? "#a78bfa" : "#d4d4d4"; + const prefix = line.type === "step" ? "▶ " : line.type === "error" ? "✗ " + : line.type === "info" ? "→ " : " "; + return ( +
+ {prefix}{line.text} +
+ ); + })} + {["running", "pending"].includes(activeSession.status) && ( + + )} +
+ + {/* Changed files */} + {activeSession.changed_files.length > 0 && ( +
+
Changed Files
+
+ {activeSession.changed_files.map((f, i) => ( +
+ {f.status === "added" ? "+" : f.status === "deleted" ? "−" : "~"} + {f.path} +
+ ))} +
+ {activeSession.status === "done" && ( +
+ + +
+ )} +
+ )} + + ) : ( +
+ Select a session or run a new task +
+ )} + + {/* Task input */} +
+
+