Files
vibn-frontend/app/[workspace]/project/[projectId]/build/page.tsx
Mark Henderson ad3abd427b feat: agent execution scaffold — sessions DB, API, and Browse/Agent/Terminal UI
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
2026-03-06 17:56:10 -08:00

724 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import Link from "next/link";
// ── Types ─────────────────────────────────────────────────────────────────────
interface AppEntry { name: string; path: string; }
interface SurfaceEntry { id: string; label: string; lockedTheme?: string; }
interface FileItem { name: string; path: string; type: "file" | "dir" | "symlink"; }
interface TreeNode {
name: string; path: string; type: "file" | "dir";
children?: TreeNode[]; expanded?: boolean; loaded?: boolean;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const INFRA_ITEMS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
];
const SURFACE_LABELS: Record<string, string> = {
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
};
const SURFACE_ICONS: Record<string, string> = {
webapp: "◈", marketing: "◌", admin: "◫",
};
// ── Language / syntax helpers ─────────────────────────────────────────────────
function langFromName(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
json: "json", md: "markdown", css: "css", scss: "css", html: "html",
py: "python", sh: "shell", yaml: "yaml", yml: "yaml",
toml: "toml", prisma: "prisma", sql: "sql",
};
return map[ext] ?? "text";
}
function highlightCode(code: string, lang: string): React.ReactNode[] {
return code.split("\n").map((line, i) => {
const commentPrefixes = ["//", "#", "--"];
if (commentPrefixes.some(p => line.trimStart().startsWith(p))) {
return <div key={i}><span style={{ color: "#6a9955" }}>{line}</span></div>;
}
const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)\b/g;
const parts = line.split(kwRe);
const tokens = parts.map((p, j) => {
if (!p) return null;
if (/^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)$/.test(p))
return <span key={j} style={{ color: "#569cd6" }}>{p}</span>;
return <span key={j}>{p}</span>;
});
return <div key={i} style={{ minHeight: "1.4em" }}>{tokens.length ? tokens : "\u00a0"}</div>;
});
}
// ── File tree row ─────────────────────────────────────────────────────────────
function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: {
node: TreeNode; depth: number; selectedPath: string | null;
onSelect: (p: string) => void; onToggle: (p: string) => void;
}) {
const active = selectedPath === node.path;
const isDir = node.type === "dir";
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
const fileColor = ext === "tsx" || ext === "ts" ? "#3178c6" : ext === "jsx" || ext === "js" ? "#f0db4f"
: ext === "css" || ext === "scss" ? "#e879f9" : "#b5b0a6";
return (
<>
<button onClick={() => isDir ? onToggle(node.path) : onSelect(node.path)} style={{
display: "flex", alignItems: "center", gap: 5, width: "100%", textAlign: "left",
background: active ? "#eceae5" : "transparent", border: "none", cursor: "pointer",
padding: `4px 8px 4px ${10 + depth * 12}px`, borderRadius: 4,
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem",
color: active ? "#1a1a1a" : "#4a4640",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f0ece4"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{isDir
? <span style={{ fontSize: "0.48rem", color: "#a09a90", display: "inline-block", transition: "transform 0.1s", transform: node.expanded ? "rotate(90deg)" : "none" }}></span>
: <span style={{ color: fileColor, fontSize: "0.65rem" }}></span>}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{node.name}</span>
</button>
{isDir && node.expanded && node.children?.map(c =>
<TreeRow key={c.path} node={c} depth={depth + 1} selectedPath={selectedPath} onSelect={onSelect} onToggle={onToggle} />
)}
</>
);
}
// ── Left nav shared styles ────────────────────────────────────────────────────
const NAV_GROUP_LABEL: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "12px 12px 5px", fontFamily: "Outfit, sans-serif",
};
function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) {
return (
<button onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 7, width: "100%", textAlign: "left",
background: active ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: `5px 12px 5px ${indent ? 22 : 12}px`, borderRadius: 5,
fontSize: "0.78rem", fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550", fontFamily: "Outfit, sans-serif",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{label}
</button>
);
}
// ── Placeholder panel ─────────────────────────────────────────────────────────
function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 60, textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: 13, background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", color: "#b5b0a6" }}>{icon}</div>
<div>
<div style={{ fontSize: "0.9rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.8rem", color: "#a09a90", maxWidth: 320, lineHeight: 1.6 }}>{desc}</div>
</div>
<div style={{ marginTop: 4, padding: "7px 18px", background: "#1a1a1a", color: "#fff", borderRadius: 7, fontSize: "0.77rem", fontWeight: 500, opacity: 0.35 }}>Coming soon</div>
</div>
);
}
// ── Infra content ─────────────────────────────────────────────────────────────
function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) {
const base = `/${workspace}/project/${projectId}/infrastructure`;
const descriptions: Record<string, { icon: string; title: string; desc: string }> = {
databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." },
services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." },
environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." },
domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." },
logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." },
builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." },
};
const d = descriptions[tab];
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ padding: "20px 28px 0", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>{tab}</div>
<Link href={`${base}?tab=${tab}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>Open full view </Link>
</div>
{d && <Placeholder icon={d.icon} title={d.title} desc={d.desc} />}
</div>
);
}
// ── Layouts content ───────────────────────────────────────────────────────────
function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: {
surfaces: SurfaceEntry[]; projectId: string; workspace: string;
activeSurfaceId: string | null; onSelectSurface: (id: string) => void;
}) {
if (surfaces.length === 0) {
return (
<Placeholder icon="◌" title="No design surfaces yet" desc="Add design surfaces from the Design page to configure your web app, marketing site, or admin panel." />
);
}
const active = surfaces.find(s => s.id === activeSurfaceId) ?? surfaces[0];
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", padding: "24px 28px", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Layouts</div>
<Link href={`/${workspace}/project/${projectId}/design?surface=${active?.id ?? ""}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>Edit in Design </Link>
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap" }}>
{surfaces.map(s => (
<div key={s.id} onClick={() => onSelectSurface(s.id)} style={{
background: active?.id === s.id ? "#fff" : "#faf8f5",
border: `1px solid ${active?.id === s.id ? "#1a1a1a" : "#e8e4dc"}`,
borderRadius: 10, padding: "16px 20px", cursor: "pointer",
minWidth: 180, flex: "1 1 180px", maxWidth: 240,
transition: "border-color 0.1s",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif", marginBottom: 4 }}>
{SURFACE_LABELS[s.id] ?? s.id}
</div>
{s.lockedTheme ? (
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "Outfit, sans-serif" }}>Theme: {s.lockedTheme}</div>
) : (
<div style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif", fontStyle: "italic" }}>Not configured</div>
)}
</div>
))}
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>
Click a surface to select it, then open the Design editor to configure themes, fonts, and components.
</div>
</div>
);
}
// ── 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 (
<div style={{ display: "flex", borderBottom: "1px solid #e8e4dc", background: "#fff", flexShrink: 0, padding: "0 16px" }}>
{tabs.map(t => (
<button key={t.id} onClick={() => onChange(t.id)} style={{
padding: "10px 14px", border: "none", background: "transparent", cursor: "pointer",
fontSize: "0.76rem", fontWeight: mode === t.id ? 600 : 450,
color: mode === t.id ? "#1a1a1a" : "#a09a90",
borderBottom: mode === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap",
}}>
{t.label}
</button>
))}
</div>
);
}
// ── 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<string, string> = {
running: "#3d5afe", done: "#2e7d32", failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a",
};
const STATUS_LABELS: Record<string, string> = {
running: "Running", done: "Done", failed: "Failed", stopped: "Stopped", pending: "Starting…",
};
const FILE_STATUS_COLORS: Record<string, string> = { 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<AgentSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [activeSession, setActiveSession] = useState<AgentSession | null>(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 (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: 40, textAlign: "center" }}>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif" }}>Select an app first</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90", fontFamily: "Outfit, sans-serif" }}>Choose an app from the left nav to run agent tasks against it.</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* Session list sidebar */}
<div style={{ width: 220, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "10px 12px 8px", borderBottom: "1px solid #e8e4dc", fontSize: "0.65rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>
Sessions
</div>
<div style={{ flex: 1, overflow: "auto" }}>
{loadingSessions && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Loading</div>}
{!loadingSessions && sessions.length === 0 && (
<div style={{ padding: "16px 12px", fontSize: "0.73rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif", lineHeight: 1.5 }}>No sessions yet. Run your first task below.</div>
)}
{sessions.map(s => (
<button key={s.id} onClick={() => { setActiveSessionId(s.id); setActiveSession(s); }} style={{
width: "100%", textAlign: "left", padding: "10px 12px", border: "none", cursor: "pointer",
background: activeSessionId === s.id ? "#f0ece4" : "transparent",
borderBottom: "1px solid #f0ece4",
}}
onMouseEnter={e => { if (activeSessionId !== s.id) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (activeSessionId !== s.id) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: STATUS_COLORS[s.status] ?? "#a09a90", flexShrink: 0, display: "inline-block" }} />
<span style={{ fontSize: "0.65rem", color: STATUS_COLORS[s.status] ?? "#a09a90", fontFamily: "Outfit, sans-serif", fontWeight: 600 }}>{STATUS_LABELS[s.status] ?? s.status}</span>
</div>
<div style={{ fontSize: "0.73rem", color: "#1a1a1a", fontFamily: "Outfit, sans-serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.task}</div>
<div style={{ fontSize: "0.65rem", color: "#a09a90", marginTop: 2, fontFamily: "Outfit, sans-serif" }}>{new Date(s.created_at).toLocaleTimeString()}</div>
</button>
))}
</div>
</div>
{/* Main panel */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Active session view */}
{activeSession ? (
<>
{/* Session header */}
<div style={{ padding: "12px 20px", borderBottom: "1px solid #e8e4dc", background: "#fff", display: "flex", alignItems: "center", gap: 12, flexShrink: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: STATUS_COLORS[activeSession.status], flexShrink: 0, display: "inline-block" }} />
<span style={{ fontSize: "0.8rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeSession.task}</span>
{activeSession.started_at && (
<span style={{ fontSize: "0.7rem", color: "#a09a90", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
{["running", "pending"].includes(activeSession.status) ? `${elapsed(activeSession.started_at)} elapsed` : elapsed(activeSession.started_at)}
</span>
)}
{["running", "pending"].includes(activeSession.status) && (
<button onClick={handleStop} style={{ padding: "4px 12px", background: "#fee2e2", color: "#c62828", border: "1px solid #fca5a5", borderRadius: 5, fontSize: "0.72rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
Stop
</button>
)}
</div>
{/* Output stream */}
<div ref={outputRef} style={{ flex: 1, overflow: "auto", background: "#1a1a1a", padding: "16px 20px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", lineHeight: 1.6 }}>
{activeSession.output.length === 0 && (
<span style={{ color: "#555" }}>Starting agent</span>
)}
{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 (
<div key={i} style={{ color, marginBottom: 1 }}>
{prefix}{line.text}
</div>
);
})}
{["running", "pending"].includes(activeSession.status) && (
<span style={{ color: "#555", animation: "pulse 1.5s infinite" }}></span>
)}
</div>
{/* Changed files */}
{activeSession.changed_files.length > 0 && (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 20px", flexShrink: 0 }}>
<div style={{ fontSize: "0.65rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif", marginBottom: 8 }}>Changed Files</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{activeSession.changed_files.map((f, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 5, background: "#faf8f5", border: "1px solid #e8e4dc", borderRadius: 5, padding: "3px 8px" }}>
<span style={{ color: FILE_STATUS_COLORS[f.status] ?? "#a09a90", fontSize: "0.65rem", fontWeight: 700 }}>{f.status === "added" ? "+" : f.status === "deleted" ? "" : "~"}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.7rem", color: "#4a4640" }}>{f.path}</span>
</div>
))}
</div>
{activeSession.status === "done" && (
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<button style={{ padding: "7px 16px", background: "#1a1a1a", color: "#fff", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif" }}>
Approve &amp; commit
</button>
<button style={{ padding: "7px 16px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif" }}>
Open in Theia
</button>
</div>
)}
</div>
)}
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.8rem", fontFamily: "Outfit, sans-serif" }}>
Select a session or run a new task
</div>
)}
{/* Task input */}
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleRun(); }}
placeholder={`Tell the agent what to build in ${appName || "this app"}`}
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "9px 12px", fontSize: "0.8rem", fontFamily: "Outfit, sans-serif",
color: "#1a1a1a", outline: "none", background: "#faf8f5",
}}
/>
<button
onClick={handleRun}
disabled={submitting || !task.trim()}
style={{
padding: "10px 18px", background: task.trim() ? "#1a1a1a" : "#e8e4dc",
color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 8,
fontSize: "0.78rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default",
fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap",
}}
>
{submitting ? "Starting…" : "Run"}
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", marginTop: 5, fontFamily: "Outfit, sans-serif" }}>
to run · Session persists if you close the browser
</div>
</div>
</div>
</div>
);
}
// ── Terminal mode (Phase 4 placeholder) ───────────────────────────────────────
function TerminalMode({ appName }: { appName: string }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#1a1a1a", overflow: "hidden" }}>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 14, padding: 40, textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: 13, background: "#252526", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.3rem" }}></div>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#d4d4d4", marginBottom: 6, fontFamily: "Outfit, sans-serif" }}>Terminal Phase 4</div>
<div style={{ fontSize: "0.78rem", color: "#6b6560", maxWidth: 300, lineHeight: 1.6, fontFamily: "Outfit, sans-serif" }}>
{appName
? `A live shell into the ${appName} container via xterm.js + Theia PTY. Coming in Phase 4.`
: "Select an app first, then open a live shell into its container."}
</div>
</div>
<div style={{ marginTop: 4, padding: "7px 18px", background: "#252526", color: "#555", borderRadius: 7, fontSize: "0.77rem", fontFamily: "Outfit, sans-serif" }}>Coming in Phase 4</div>
</div>
</div>
);
}
// ── Code content (file browser) ───────────────────────────────────────────────
function CodeContent({ projectId, appName, rootPath }: { projectId: string; appName: string; rootPath: string }) {
const { status } = useSession();
const [tree, setTree] = useState<TreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const fetchDir = useCallback(async (path: string): Promise<TreeNode[]> => {
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const items: FileItem[] = data.items ?? [];
return items.filter(i => i.type !== "symlink")
.sort((a, b) => a.type === "dir" && b.type !== "dir" ? -1 : a.type !== "dir" && b.type === "dir" ? 1 : a.name.localeCompare(b.name))
.map(i => ({ name: i.name, path: i.path, type: i.type === "dir" ? "dir" : "file", expanded: false, loaded: i.type !== "dir", children: i.type === "dir" ? [] : undefined }));
}, [projectId]);
useEffect(() => {
if (!rootPath || status !== "authenticated") return;
setTree([]); setSelectedPath(null); setFileContent(null); setTreeLoading(true);
fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false));
}, [rootPath, status, fetchDir]);
const handleToggle = useCallback(async (path: string) => {
setTree(prev => {
const toggle = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, expanded: !n.expanded } : n.children ? { ...n, children: toggle(n.children) } : n);
return toggle(prev);
});
const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { for (const n of nodes) { if (n.path === p) return n; if (n.children) { const f = findNode(n.children, p); if (f) return f; } } return null; };
const node = findNode(tree, path);
if (node && !node.loaded) {
const children = await fetchDir(path).catch(() => []);
setTree(prev => {
const update = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, children, loaded: true } : n.children ? { ...n, children: update(n.children) } : n);
return update(prev);
});
}
}, [tree, fetchDir]);
const handleSelectFile = useCallback(async (path: string) => {
setSelectedPath(path); setFileContent(null); setFileName(path.split("/").pop() ?? null); setFileLoading(true);
try {
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
setFileContent(data.content ?? "");
} catch { setFileContent("// Failed to load"); }
finally { setFileLoading(false); }
}, [projectId]);
const lang = fileName ? langFromName(fileName) : "text";
const lines = (fileContent ?? "").split("\n");
if (!appName) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 12, padding: 40, textAlign: "center" }}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.3rem", color: "#b5b0a6" }}></div>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6, fontFamily: "Outfit, sans-serif" }}>Select an app</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 240, lineHeight: 1.5, fontFamily: "Outfit, sans-serif" }}>Choose an app from the left to browse its source files.</div>
</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* File tree */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "10px 12px 8px", borderBottom: "1px solid #e8e4dc", display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
<span style={{ fontSize: "0.7rem", color: "#a09a90" }}></span>
<span style={{ fontSize: "0.76rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif" }}>{appName}</span>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "4px" }}>
{treeLoading && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Loading</div>}
{!treeLoading && tree.length === 0 && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Empty.</div>}
{tree.map(n => <TreeRow key={n.path} node={n} depth={0} selectedPath={selectedPath} onSelect={handleSelectFile} onToggle={handleToggle} />)}
</div>
</div>
{/* Code viewer */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#1e1e1e", overflow: "hidden" }}>
<div style={{ padding: "9px 18px", borderBottom: "1px solid #2d2d2d", background: "#252526", display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
{selectedPath ? (
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", color: "#a09a90" }}>
{(() => { const rel = selectedPath.startsWith(rootPath + "/") ? selectedPath.slice(rootPath.length + 1) : selectedPath; return rel.split("/").map((s, i, a) => <span key={i}>{i > 0 && <span style={{ color: "#555", margin: "0 3px" }}>/</span>}<span style={{ color: i === a.length - 1 ? "#d4d4d4" : "#888" }}>{s}</span></span>); })()}
</span>
) : <span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", color: "#555" }}>Select a file</span>}
{fileName && <span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.62rem", color: "#555", textTransform: "uppercase" }}>{lang}</span>}
</div>
<div style={{ flex: 1, overflow: "auto", display: "flex" }}>
{!selectedPath && !fileLoading && <div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.78rem", fontFamily: "IBM Plex Mono, monospace" }}>Select a file to view</div>}
{fileLoading && <div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.78rem", fontFamily: "IBM Plex Mono, monospace" }}>Loading</div>}
{!fileLoading && fileContent !== null && (
<div style={{ display: "flex", width: "100%" }}>
<div style={{ padding: "14px 0", background: "#1e1e1e", borderRight: "1px solid #2d2d2d", textAlign: "right", userSelect: "none", flexShrink: 0, minWidth: 40 }}>
{lines.map((_, i) => <div key={i} style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", lineHeight: "1.4em", color: "#555", padding: "0 10px 0 6px" }}>{i + 1}</div>)}
</div>
<div style={{ padding: "14px 22px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", lineHeight: "1.4em", color: "#d4d4d4", flex: 1, whiteSpace: "pre", overflow: "auto" }}>
{highlightCode(fileContent, lang)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Main Build hub ────────────────────────────────────────────────────────────
function BuildHubInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const section = searchParams.get("section") ?? "code";
const activeApp = searchParams.get("app") ?? "";
const activeRoot = searchParams.get("root") ?? "";
const activeInfra = searchParams.get("tab") ?? "builds";
const activeSurfaceParam = searchParams.get("surface") ?? "";
const [apps, setApps] = useState<AppEntry[]>([]);
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`).then(r => r.json()).then(d => setApps(d.apps ?? [])).catch(() => {});
fetch(`/api/projects/${projectId}/design-surfaces`).then(r => r.json()).then(d => {
const ids: string[] = d.surfaces ?? [];
const themes: Record<string, string> = d.surfaceThemes ?? {};
setSurfaces(ids.map(id => ({ id, label: SURFACE_LABELS[id] ?? id, lockedTheme: themes[id] })));
if (!activeSurfaceId && ids.length > 0) setActiveSurfaceId(ids[0]);
}).catch(() => {});
}, [projectId]);
const navigate = (params: Record<string, string>) => {
const sp = new URLSearchParams({ section, ...params });
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
const setSection = (s: string) => router.push(`/${workspace}/project/${projectId}/build?section=${s}`, { scroll: false });
const codeMode = (searchParams.get("mode") as CodeMode | null) ?? "browse";
const setCodeMode = (m: CodeMode) => {
const sp = new URLSearchParams({ section: "code", ...(activeApp ? { app: activeApp, root: activeRoot } : {}), mode: m });
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
{/* ── Left nav ── */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
{/* Code group */}
<div style={NAV_GROUP_LABEL}>Code</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={section === "code" && activeApp === app.name}
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
/>
)) : (
<NavItem label="No apps yet" indent active={section === "code" && !activeApp} onClick={() => setSection("code")} />
)}
{/* Layouts group */}
<div style={{ ...NAV_GROUP_LABEL, marginTop: 8 }}>Layouts</div>
{surfaces.length > 0 ? surfaces.map(s => (
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
active={section === "layouts" && activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<NavItem label="Not configured" indent active={section === "layouts"} onClick={() => setSection("layouts")} />
)}
{/* Infrastructure group */}
<div style={{ ...NAV_GROUP_LABEL, marginTop: 8 }}>Infrastructure</div>
{INFRA_ITEMS.map(item => (
<NavItem key={item.id} label={item.label} indent
active={section === "infrastructure" && activeInfra === item.id}
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
/>
))}
</div>
{/* ── Content ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<ModeTabs mode={codeMode} onChange={setCodeMode} />
{codeMode === "browse" && <CodeContent projectId={projectId} appName={activeApp} rootPath={activeRoot} />}
{codeMode === "agent" && <AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />}
{codeMode === "terminal" && <TerminalMode appName={activeApp} />}
</div>
)}
{section === "layouts" && (
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
)}
{section === "infrastructure" && (
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
)}
</div>
</div>
);
}
export default function BuildPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "Outfit, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<BuildHubInner />
</Suspense>
);
}