Files
vibn-frontend/app/[workspace]/project/[projectId]/build/page.tsx
Mark Henderson 01848ba682 feat: add persistent COO/Assist chat as left-side primary AI interface
- New CooChat component: streaming Gemini-backed advisor chat, message
  bubbles, typing cursor animation, Shift+Enter for newlines
- New /api/projects/[projectId]/advisor streaming endpoint: builds a
  COO system prompt from project context (name, description, vision,
  repo), proxies Gemini SSE stream back to the client
- Restructured BuildHubInner layout:
    Left (340px): CooChat — persistent across all Build sections
    Inner nav (200px): Build pills + contextual items (apps, tree, surfaces)
    Main area: File viewer for Code, Layouts content, Infra content
- AgentMode removed from main view — execution surfaces via COO delegation

Made-with: Cursor
2026-03-09 15:34:41 -07:00

1296 lines
70 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, useRef } 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 ───────────────────────────────────────────────────────
// ── Agent mode ────────────────────────────────────────────────────────────────
interface AgentSession {
id: string;
app_name: string;
task: string;
status: "pending" | "running" | "done" | "approved" | "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", approved: "#1b5e20",
failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a",
};
const STATUS_LABELS: Record<string, string> = {
running: "Running", done: "Done", approved: "Shipped",
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 [approving, setApproving] = useState(false);
const [approveMsg, setApproveMsg] = useState("");
const [showApproveInput, setShowApproveInput] = useState(false);
const [approveResult, setApproveResult] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [followUp, setFollowUp] = useState("");
const [showFollowUp, setShowFollowUp] = useState(false);
const outputRef = useCallback((el: HTMLDivElement | null) => {
if (el) el.scrollTop = el.scrollHeight;
}, []);
// Load session list — auto-select the most recent active or last session
useEffect(() => {
if (!appName) return;
setLoadingSessions(true);
fetch(`/api/projects/${projectId}/agent/sessions`)
.then(r => r.json())
.then(d => {
const list: AgentSession[] = d.sessions ?? [];
setSessions(list);
setLoadingSessions(false);
// Auto-select: prefer live session, then the most recent
if (list.length > 0 && !activeSessionId) {
const live = list.find(s => ["running", "pending"].includes(s.status));
const pick = live ?? list[0];
setActiveSessionId(pick.id);
setActiveSession(pick);
}
})
.catch(() => setLoadingSessions(false));
}, [projectId, appName]); // eslint-disable-line react-hooks/exhaustive-deps
// Adaptive polling — 500ms while running, 5s when idle
useEffect(() => {
if (!activeSessionId) return;
let cancelled = false;
const poll = async () => {
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`);
const d = await r.json();
if (!cancelled && d.session) {
setActiveSession(d.session);
// Refresh session list so status dots update in sidebar
if (!["running", "pending"].includes(d.session.status)) {
fetch(`/api/projects/${projectId}/agent/sessions`)
.then(r2 => r2.json())
.then(d2 => { if (!cancelled) setSessions(d2.sessions ?? []); })
.catch(() => {});
}
}
} catch { /* network hiccup — ignore */ }
};
poll();
const isLive = ["running", "pending"].includes(activeSession?.status ?? "");
const interval = setInterval(poll, isLive ? 500 : 5000);
return () => { cancelled = true; clearInterval(interval); };
}, [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);
};
const handleRetry = async (continueTask?: string) => {
if (!activeSessionId) return;
setRetrying(true);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/retry`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ continueTask: continueTask?.trim() || undefined }),
});
const d = await r.json();
if (d.sessionId) {
setActiveSession(prev => prev ? { ...prev, status: "running", output: [], error: null } : null);
setShowFollowUp(false);
setFollowUp("");
}
} finally { setRetrying(false); }
};
const handleApprove = async () => {
if (!activeSessionId || !approveMsg.trim()) return;
setApproving(true);
setApproveResult(null);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ commitMessage: approveMsg.trim() }),
});
const d = await r.json() as { ok?: boolean; committed?: boolean; deployed?: boolean; message?: string; error?: string };
if (d.ok) {
setApproveResult(d.deployed
? `✓ Committed & deployment triggered — ${d.message}`
: `✓ Committed — ${d.message}`);
setShowApproveInput(false);
setApproveMsg("");
// Refresh session
const s = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`).then(r2 => r2.json());
if (s.session) setActiveSession(s.session);
} else {
setApproveResult(`${d.error ?? "Failed to commit"}`);
}
} catch (e) {
setApproveResult("✗ Network error — please try again");
} finally {
setApproving(false);
}
};
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", flexDirection: "column", overflow: "hidden" }}>
{/* Horizontal sessions strip */}
<div style={{ flexShrink: 0, height: 38, borderBottom: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", alignItems: "center", gap: 8, padding: "0 16px", overflowX: "auto" }}>
<span style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.09em", textTransform: "uppercase", whiteSpace: "nowrap", fontFamily: "Outfit, sans-serif" }}>Sessions</span>
<span style={{ width: 1, height: 14, background: "#e0dcd5", flexShrink: 0 }} />
{loadingSessions && <span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Loading</span>}
{!loadingSessions && sessions.length === 0 && (
<span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>No sessions yet run your first task below</span>
)}
{sessions.map(s => (
<button key={s.id} onClick={() => { setActiveSessionId(s.id); setActiveSession(s); }} style={{
padding: "3px 10px", border: `1px solid ${activeSessionId === s.id ? "#1a1a1a" : "#e0dcd5"}`,
borderRadius: 20, background: activeSessionId === s.id ? "#1a1a1a" : "transparent",
color: activeSessionId === s.id ? "#fff" : "#5a5550",
fontSize: "0.68rem", cursor: "pointer", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 5,
fontFamily: "Outfit, sans-serif", flexShrink: 0,
}}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: activeSessionId === s.id ? "#fff" : (STATUS_COLORS[s.status] ?? "#a09a90"), display: "inline-block", flexShrink: 0 }} />
{s.task.length > 30 ? s.task.slice(0, 30) + "…" : s.task}
</button>
))}
{!loadingSessions && sessions.length > 0 && (
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }} style={{
marginLeft: "auto", padding: "3px 10px", border: "1px solid #e0dcd5", borderRadius: 20,
background: "transparent", color: "#a09a90", fontSize: "0.68rem", cursor: "pointer",
fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap", flexShrink: 0,
}}>+ New</button>
)}
</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>
{/* Retry / follow-up panel for failed or stopped sessions */}
{["failed", "stopped"].includes(activeSession.status) && (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 20px", flexShrink: 0 }}>
{showFollowUp ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "Outfit, sans-serif" }}>
Add a follow-up instruction (optional) then retry:
</div>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
autoFocus
value={followUp}
onChange={e => setFollowUp(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleRetry(followUp); if (e.key === "Escape") setShowFollowUp(false); }}
placeholder="e.g. Also update the TypeScript types, or just leave blank to retry as-is…"
rows={2}
style={{ flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7, padding: "8px 11px", fontSize: "0.78rem", fontFamily: "Outfit, sans-serif", outline: "none", background: "#faf8f5" }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button onClick={() => handleRetry(followUp)} disabled={retrying}
style={{ padding: "8px 14px", background: "#1a1a1a", color: "#fff", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
{retrying ? "Retrying…" : "Retry"}
</button>
<button onClick={() => setShowFollowUp(false)}
style={{ padding: "6px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.73rem", cursor: "pointer", fontFamily: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>
to retry · Esc to cancel · Same session, fresh run
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ fontSize: "0.75rem", color: activeSession.status === "failed" ? "#c62828" : "#a09a90", fontFamily: "Outfit, sans-serif", flex: 1 }}>
{activeSession.status === "failed" ? "Session failed — retry without losing context" : "Session stopped"}
</div>
<button onClick={() => handleRetry()} disabled={retrying}
style={{ padding: "7px 14px", background: "#fef9f0", color: "#92400e", border: "1px solid #fde68a", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
{retrying ? "Retrying…" : "↺ Retry"}
</button>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
Follow up
</button>
</div>
)}
</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 === "approved" && (
<div style={{ marginTop: 8, fontSize: "0.73rem", color: "#1b5e20", fontFamily: "Outfit, sans-serif" }}>
Auto-committed these changes are live.
</div>
)}
{activeSession.status === "done" && (
<div style={{ marginTop: 12 }}>
{approveResult && (
<div style={{
marginBottom: 10, padding: "8px 12px", borderRadius: 6, fontSize: "0.74rem",
fontFamily: "Outfit, sans-serif",
background: approveResult.startsWith("✓") ? "#f0fdf4" : "#fef2f2",
color: approveResult.startsWith("✓") ? "#166534" : "#991b1b",
border: `1px solid ${approveResult.startsWith("✓") ? "#bbf7d0" : "#fecaca"}`,
}}>
{approveResult}
</div>
)}
{showApproveInput ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<input
autoFocus
value={approveMsg}
onChange={e => setApproveMsg(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleApprove(); if (e.key === "Escape") setShowApproveInput(false); }}
placeholder="Commit message…"
style={{
width: "100%", padding: "8px 11px", border: "1px solid #e8e4dc",
borderRadius: 6, fontSize: "0.78rem", fontFamily: "Outfit, sans-serif",
outline: "none", background: "#faf8f5", boxSizing: "border-box",
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={handleApprove}
disabled={approving || !approveMsg.trim()}
style={{
padding: "7px 16px", background: approveMsg.trim() ? "#1a1a1a" : "#e8e4dc",
color: approveMsg.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7,
fontSize: "0.75rem", fontWeight: 600, cursor: approveMsg.trim() ? "pointer" : "default",
fontFamily: "Outfit, sans-serif",
}}
>
{approving ? "Committing…" : "Commit & push"}
</button>
<button onClick={() => setShowApproveInput(false)} style={{ padding: "7px 12px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>
Enter to commit · Esc to cancel · Coolify will auto-deploy after push
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => { setShowApproveInput(true); setApproveMsg(`agent: ${activeSession.task.slice(0, 60)}`); }}
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>
<a
href="https://theia.vibnai.com"
target="_blank" rel="noreferrer"
style={{ padding: "7px 16px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", textDecoration: "none", display: "inline-flex", alignItems: "center" }}
>
Open in Theia
</a>
</div>
)}
</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 — context-aware */}
{(() => {
const st = activeSession?.status;
// Agent is actively working — lock input
if (st === "running" || st === "pending") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#3d5afe", flexShrink: 0, display: "inline-block" }} />
<span style={{ fontSize: "0.78rem", color: "#6b6560", fontFamily: "Outfit, sans-serif", flex: 1 }}>
Agent is working wait for it to finish, or stop it above.
</span>
</div>
</div>
);
}
// Session auto-committed — show confirmation, offer next task
if (st === "approved") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: "#1b5e20", fontFamily: "Outfit, sans-serif", flex: 1 }}>
Shipped changes committed and deployment triggered automatically.
</span>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
+ Follow up
</button>
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }}
style={{ padding: "7px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
New task
</button>
</div>
{showFollowUp && (
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginTop: 10 }}>
<textarea
autoFocus
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { handleRun(); setShowFollowUp(false); }
if (e.key === "Escape") { setShowFollowUp(false); setTask(""); }
}}
placeholder="What should the agent build next?"
rows={2}
style={{ flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7, padding: "8px 11px", fontSize: "0.78rem", fontFamily: "Outfit, sans-serif", outline: "none", background: "#faf8f5" }}
/>
<button onClick={() => { handleRun(); setShowFollowUp(false); }} disabled={submitting || !task.trim()}
style={{ padding: "9px 14px", background: task.trim() ? "#1a1a1a" : "#e8e4dc", color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default", fontFamily: "Outfit, sans-serif" }}>
{submitting ? "Starting…" : "Run"}
</button>
</div>
)}
</div>
);
}
// Session completed successfully — offer follow-up or new task
if (st === "done") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
{showFollowUp ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "Outfit, sans-serif" }}>
Describe the next thing to build (continues from this session's context):
</div>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
autoFocus
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { handleRun(); setShowFollowUp(false); }
if (e.key === "Escape") { setShowFollowUp(false); setTask(""); }
}}
placeholder="e.g. Now add email notifications for the same feature…"
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7,
padding: "8px 11px", fontSize: "0.78rem", fontFamily: "Outfit, sans-serif",
outline: "none", background: "#faf8f5",
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button onClick={() => { handleRun(); setShowFollowUp(false); }} disabled={submitting || !task.trim()}
style={{ padding: "8px 14px", background: task.trim() ? "#1a1a1a" : "#e8e4dc", color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
{submitting ? "Starting…" : "Run"}
</button>
<button onClick={() => { setShowFollowUp(false); setTask(""); }}
style={{ padding: "6px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.73rem", cursor: "pointer", fontFamily: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>
⌘↵ to run · Esc to cancel · Starts a new session for this task
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: "#2e7d32", fontFamily: "Outfit, sans-serif", flex: 1 }}>
✓ Done — approve the changes above, or continue building.
</span>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
+ Follow up
</button>
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }}
style={{ padding: "7px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
New task
</button>
</div>
)}
</div>
);
}
// No active session (or failed/stopped handled above) — new task input
if (!activeSession) {
return (
<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={`Describe 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 start · The agent works autonomously until done · You approve before anything is committed
</div>
</div>
);
}
return null;
})()}
</div>
</div>
);
}
// ── Terminal panel — collapsible bottom strip ─────────────────────────────────
function TerminalPanel({ appName }: { appName: string }) {
const [open, setOpen] = useState(false);
const PANEL_HEIGHT = 220;
return (
<div style={{ flexShrink: 0, borderTop: "1px solid #2d2d2d", background: "#1a1a1a", display: "flex", flexDirection: "column", height: open ? PANEL_HEIGHT : 32, transition: "height 0.2s ease", overflow: "hidden" }}>
{/* Header / toggle bar */}
<button
onClick={() => setOpen(o => !o)}
style={{
display: "flex", alignItems: "center", gap: 8, padding: "0 14px",
height: 32, flexShrink: 0, width: "100%", border: "none",
background: "transparent", cursor: "pointer", textAlign: "left",
}}
>
<span style={{ fontSize: "0.58rem", color: "#555", transform: open ? "rotate(180deg)" : "none", transition: "transform 0.2s", display: "inline-block" }}>▲</span>
<span style={{ fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", letterSpacing: "0.07em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Terminal</span>
{appName && <span style={{ fontSize: "0.65rem", color: "#3a3a3a", fontFamily: "IBM Plex Mono, monospace" }}>{appName}</span>}
<span style={{ marginLeft: "auto", fontSize: "0.6rem", color: "#3a3a3a", fontFamily: "Outfit, sans-serif" }}>Phase 4</span>
</button>
{/* Body */}
{open && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: "16px 24px", textAlign: "center" }}>
<div style={{ fontSize: "0.78rem", color: "#6b6560", lineHeight: 1.6, fontFamily: "Outfit, sans-serif", maxWidth: 340 }}>
{appName
? `Live shell into the ${appName} container via xterm.js + Theia PTY — coming in Phase 4.`
: "Select an app from the left, then open a live shell into its container."}
</div>
</div>
)}
</div>
);
}
// ── COO / Assist chat — persistent left-side advisor ─────────────────────────
interface CooMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const WELCOME: CooMessage = {
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO I know your codebase, your goals, and what's been built. What do you need?",
};
function CooChat({ projectId, projectName }: { projectId: string; projectName: string }) {
const [messages, setMessages] = useState<CooMessage[]>([WELCOME]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text };
const assistantId = (Date.now() + 1).toString();
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", streaming: true };
setMessages(prev => [...prev, userMsg, assistantMsg]);
setLoading(true);
// Build history for the API (exclude welcome message, exclude the new blank assistant placeholder)
const history = messages
.filter(m => m.id !== "welcome" && m.content)
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
try {
const res = await fetch(`/api/projects/${projectId}/advisor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
});
if (!res.ok || !res.body) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
: m));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: m.content + chunk }
: m));
}
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
} catch {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Connection error. Please try again.", streaming: false }
: m));
} finally {
setLoading(false);
textareaRef.current?.focus();
}
};
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Messages */}
<div style={{ flex: 1, overflow: "auto", padding: "16px 16px 8px", display: "flex", flexDirection: "column", gap: 14 }}>
{messages.map(msg => (
<div key={msg.id} style={{
display: "flex",
flexDirection: msg.role === "user" ? "row-reverse" : "row",
alignItems: "flex-end", gap: 8,
}}>
{msg.role === "assistant" && (
<span style={{ width: 22, height: 22, borderRadius: 6, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.58rem", color: "#fff", flexShrink: 0 }}>◈</span>
)}
<div style={{
maxWidth: "82%",
padding: msg.role === "user" ? "8px 12px" : "0",
background: msg.role === "user" ? "#f0ece4" : "transparent",
borderRadius: msg.role === "user" ? 12 : 0,
fontSize: "0.8rem",
color: "#1a1a1a",
fontFamily: "Outfit, sans-serif",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{msg.content || (msg.streaming ? "" : "")}
{msg.streaming && msg.content === "" && (
<span style={{ display: "inline-flex", gap: 3, alignItems: "center" }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%", background: "#b5b0a6", display: "inline-block",
animation: `bounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</span>
)}
{msg.streaming && msg.content !== "" && (
<span style={{ display: "inline-block", width: 2, height: "0.9em", background: "#1a1a1a", marginLeft: 1, verticalAlign: "text-bottom", animation: "blink 1s step-end infinite" }} />
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px", background: "#fff" }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={loading ? "Thinking" : "Ask anything"}
disabled={loading}
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "8px 11px", fontSize: "0.8rem", fontFamily: "Outfit, sans-serif",
color: "#1a1a1a", outline: "none", background: loading ? "#faf8f5" : "#faf8f5",
lineHeight: 1.5,
}}
/>
<button
onClick={send}
disabled={!input.trim() || loading}
style={{
width: 34, height: 34, flexShrink: 0, border: "none", borderRadius: 9,
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
cursor: input.trim() && !loading ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.85rem", transition: "background 0.15s",
}}
>↑</button>
</div>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", marginTop: 5, fontFamily: "Outfit, sans-serif" }}>
↵ to send · Shift+↵ new line
</div>
</div>
<style>{`
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
// ── File tree (lives in sidebar B) ───────────────────────────────────────────
function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
projectId: string; rootPath: string; selectedPath: string | null;
onSelectFile: (path: string) => void;
}) {
const { status } = useSession();
const [tree, setTree] = useState<TreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(false);
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([]); 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]);
return (
<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={onSelectFile} onToggle={handleToggle} />)}
</div>
);
}
// ── File viewer (right panel) ─────────────────────────────────────────────────
function FileViewer({ selectedPath, fileContent, fileLoading, fileName, rootPath }: {
selectedPath: string | null; fileContent: string | null;
fileLoading: boolean; fileName: string | null; rootPath: string;
}) {
const lang = fileName ? langFromName(fileName) : "text";
const lines = (fileContent ?? "").split("\n");
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#1e1e1e", overflow: "hidden" }}>
{/* File path header */}
<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 from the tree</span>
)}
{fileName && <span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.62rem", color: "#555", textTransform: "uppercase" }}>{lang}</span>}
</div>
{/* Content */}
<div style={{ flex: 1, overflow: "auto", display: "flex" }}>
{!selectedPath && !fileLoading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", flexDirection: "column", gap: 10, color: "#3a3a3a" }}>
<span style={{ fontSize: "1.6rem" }}>◌</span>
<span style={{ fontSize: "0.75rem", fontFamily: "IBM Plex Mono, monospace", color: "#555" }}>Select a file to view</span>
</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>
);
}
// ── 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);
const [projectName, setProjectName] = useState<string>("");
// File viewer state — shared between inner nav (tree) and viewer panel
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => {
setApps(d.apps ?? []);
if (d.projectName) setProjectName(d.projectName);
}).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]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setSelectedFilePath(null);
setFileContent(null);
setFileName(null);
}, [activeApp]);
const handleSelectFile = async (path: string) => {
setSelectedFilePath(path);
setFileName(path.split("/").pop() ?? null);
setFileContent(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); }
};
const navigate = (navParams: Record<string, string>) => {
const sp = new URLSearchParams({ section, ...navParams });
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
const setSection = (s: string) => router.push(`/${workspace}/project/${projectId}/build?section=${s}`, { scroll: false });
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
{/* ── Left: COO / Assist persistent chat ── */}
<div style={{ width: 340, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#fff", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Advisor header */}
<div style={{ height: 48, flexShrink: 0, display: "flex", alignItems: "center", padding: "0 14px", borderBottom: "1px solid #e8e4dc", gap: 9, background: "#faf8f5" }}>
<span style={{ width: 26, height: 26, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", color: "#fff", flexShrink: 0 }}></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.78rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projectName || workspace}
</div>
<div style={{ fontSize: "0.6rem", color: "#a09a90", fontFamily: "Outfit, sans-serif", letterSpacing: "0.04em" }}>Assist · Your product COO</div>
</div>
</div>
<CooChat projectId={projectId} projectName={projectName || workspace} />
</div>
{/* ── Right: Build content ── */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Inner nav — Build section switcher + contextual items */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Build pills */}
<div style={{ padding: "10px 12px 9px", flexShrink: 0, borderBottom: "1px solid #f0ece4" }}>
<div style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 7, fontFamily: "Outfit, sans-serif" }}>Build</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{(["code", "layouts", "infrastructure"] as const).map(s => (
<button key={s} onClick={() => setSection(s)} style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 10px", border: "none", borderRadius: 7,
fontSize: "0.76rem", fontWeight: section === s ? 600 : 440,
cursor: "pointer", fontFamily: "Outfit, sans-serif", textAlign: "left",
background: section === s ? "#f0ece4" : "transparent",
color: section === s ? "#1a1a1a" : "#5a5550",
}}>
<span style={{ fontSize: "0.65rem", opacity: 0.6 }}>
{s === "code" ? "<>" : s === "layouts" ? "▢" : "⬡"}
</span>
{s === "code" ? "Code" : s === "layouts" ? "Layouts" : "Infrastructure"}
</button>
))}
</div>
</div>
{/* Code: app list + file tree */}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flexShrink: 0 }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No apps yet</div>
)}
</div>
{activeApp && activeRoot && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Files</span>
<span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
</div>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
)}
</div>
)}
{/* Layouts: surface list */}
{section === "layouts" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Surfaces</div>
{surfaces.length > 0 ? surfaces.map(s => (
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
active={activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Not configured</div>
)}
</div>
)}
{/* Infrastructure: item list */}
{section === "infrastructure" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Infrastructure</div>
{INFRA_ITEMS.map(item => (
<NavItem key={item.id} label={item.label} indent
active={activeInfra === item.id}
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
/>
))}
</div>
)}
</div>
{/* Main content panel */}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<FileViewer
selectedPath={selectedFilePath}
fileContent={fileContent}
fileLoading={fileLoading}
fileName={fileName}
rootPath={activeRoot}
/>
<TerminalPanel 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>
);
}