Files
vibn-frontend/app/[workspace]/project/[projectId]/overview/page.tsx
Mark Henderson 26a11412b5 feat: add Atlas discovery chat UI and API route
- components/AtlasChat.tsx — conversational PRD discovery UI (violet theme)
- app/api/projects/[projectId]/atlas-chat/route.ts — proxy + DB persistence
- overview/page.tsx — show Atlas for new projects, Orchestrator once PRD done

Made-with: Cursor
2026-03-01 15:56:32 -08:00

525 lines
19 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OrchestratorChat } from "@/components/OrchestratorChat";
import { AtlasChat } from "@/components/AtlasChat";
import {
GitBranch,
GitCommit,
GitPullRequest,
CircleDot,
ExternalLink,
Terminal,
Rocket,
Database,
Loader2,
CheckCircle2,
XCircle,
Clock,
AlertCircle,
Code2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
interface ContextSnapshot {
lastCommit?: {
sha: string;
message: string;
author?: string;
timestamp?: string;
url?: string;
};
currentBranch?: string;
recentCommits?: { sha: string; message: string; author?: string; timestamp?: string }[];
openPRs?: { number: number; title: string; url: string; from: string; into: string }[];
openIssues?: { number: number; title: string; url: string; labels?: string[] }[];
lastDeployment?: {
status: string;
url?: string;
timestamp?: string;
deploymentUuid?: string;
};
updatedAt?: string;
}
interface Project {
id: string;
name: string;
productName: string;
productVision?: string;
slug?: string;
workspace?: string;
status?: string;
currentPhase?: string;
projectType?: string;
// Gitea
giteaRepo?: string;
giteaRepoUrl?: string;
giteaCloneUrl?: string;
giteaSshUrl?: string;
giteaWebhookId?: number;
giteaError?: string;
// Coolify
coolifyProjectUuid?: string;
coolifyAppUuid?: string;
coolifyDbUuid?: string;
deploymentUrl?: string;
// Theia
theiaWorkspaceUrl?: string;
// Stage
stage?: 'discovery' | 'architecture' | 'building' | 'active';
prd?: string;
// Context
contextSnapshot?: ContextSnapshot;
stats?: { sessions: number; costs: number };
createdAt?: string;
updatedAt?: string;
}
function timeAgo(ts?: string): string {
if (!ts) return "—";
const d = new Date(ts);
if (isNaN(d.getTime())) return "—";
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function DeployBadge({ status }: { status?: string }) {
if (!status) return <Badge variant="secondary">No deployments</Badge>;
const map: Record<string, { label: string; icon: React.ElementType; className: string }> = {
finished: { label: "Deployed", icon: CheckCircle2, className: "bg-green-500/10 text-green-600 border-green-500/20" },
in_progress: { label: "Deploying", icon: Loader2, className: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
queued: { label: "Queued", icon: Clock, className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" },
failed: { label: "Failed", icon: XCircle, className: "bg-red-500/10 text-red-600 border-red-500/20" },
cancelled: { label: "Cancelled", icon: XCircle, className: "bg-gray-500/10 text-gray-500 border-gray-500/20" },
};
const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" };
const Icon = cfg.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${cfg.className}`}>
<Icon className="h-3 w-3" />
{cfg.label}
</span>
);
}
export default function ProjectOverviewPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const { status: authStatus } = useSession();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const fetchProject = async () => {
try {
const res = await fetch(`/api/projects/${projectId}`);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to load project");
}
const data = await res.json();
setProject(data.project);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
if (authStatus === "authenticated") fetchProject();
else if (authStatus === "unauthenticated") setLoading(false);
}, [authStatus, projectId]);
const handleRefresh = () => {
setRefreshing(true);
fetchProject();
};
const handleProvisionWorkspace = async () => {
setProvisioning(true);
try {
const res = await fetch(`/api/projects/${projectId}/workspace`, { method: 'POST' });
const data = await res.json();
if (res.ok && data.workspaceUrl) {
toast.success('Workspace provisioned — starting up…');
await fetchProject();
} else {
toast.error(data.error || 'Failed to provision workspace');
}
} catch {
toast.error('An error occurred');
} finally {
setProvisioning(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !project) {
return (
<div className="container mx-auto py-8 px-6 max-w-5xl">
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="py-8 text-center">
<p className="text-sm text-red-600">{error ?? "Project not found"}</p>
</CardContent>
</Card>
</div>
);
}
const snap = project.contextSnapshot;
const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com";
return (
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
{/* ── Agent Panel — Atlas for discovery, Orchestrator once PRD is done ── */}
{(!project.stage || project.stage === 'discovery') ? (
<AtlasChat
projectId={projectId}
projectName={project.productName}
/>
) : (
<OrchestratorChat
projectId={projectId}
projectName={project.productName}
/>
)}
{/* ── Header ── */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">{project.productName}</h1>
{project.productVision && (
<p className="text-muted-foreground text-sm mt-1 max-w-xl">{project.productVision}</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant={project.status === "active" ? "default" : "secondary"}>
{project.status ?? "active"}
</Badge>
{project.currentPhase && (
<Badge variant="outline">{project.currentPhase}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
{project.theiaWorkspaceUrl ? (
<Button size="sm" asChild>
<a href={project.theiaWorkspaceUrl} target="_blank" rel="noopener noreferrer">
<Terminal className="h-4 w-4 mr-1.5" />
Open IDE
</a>
</Button>
) : (
<Button size="sm" onClick={handleProvisionWorkspace} disabled={provisioning}>
{provisioning
? <><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Provisioning</>
: <><Terminal className="h-4 w-4 mr-1.5" />Provision IDE</>
}
</Button>
)}
</div>
</div>
{/* ── Quick Stats ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Sessions", value: project.stats?.sessions ?? 0 },
{ label: "AI Cost", value: `$${(project.stats?.costs ?? 0).toFixed(2)}` },
{ label: "Open PRs", value: snap?.openPRs?.length ?? 0 },
{ label: "Open Issues", value: snap?.openIssues?.length ?? 0 },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-5 pb-4">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* ── Code / Gitea ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Code2 className="h-4 w-4" />
Code Repository
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{project.giteaRepo ? (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-mono text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
{snap?.currentBranch ?? "main"}
</div>
<a
href={project.giteaRepoUrl ?? `${gitea_url}/${project.giteaRepo}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary flex items-center gap-1 hover:underline"
>
{project.giteaRepo}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{snap?.lastCommit ? (
<div className="rounded-md border bg-muted/30 p-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GitCommit className="h-3.5 w-3.5" />
<span className="font-mono">{snap.lastCommit.sha.slice(0, 8)}</span>
<span>·</span>
<span>{timeAgo(snap.lastCommit.timestamp)}</span>
{snap.lastCommit.author && <span>· {snap.lastCommit.author}</span>}
</div>
<p className="text-sm font-medium line-clamp-1">{snap.lastCommit.message}</p>
</div>
) : (
<p className="text-xs text-muted-foreground">No commits yet push to get started</p>
)}
<div className="text-xs text-muted-foreground space-y-1 pt-1 border-t">
<p className="font-medium text-foreground">Clone</p>
<p className="font-mono break-all">{project.giteaCloneUrl}</p>
{project.giteaSshUrl && (
<p className="font-mono break-all">{project.giteaSshUrl}</p>
)}
</div>
</>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">
{project.giteaError
? `Repo provisioning failed: ${project.giteaError}`
: "No repository linked"}
</p>
</div>
)}
</CardContent>
</Card>
{/* ── Deployment ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Rocket className="h-4 w-4" />
Deployment
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{snap?.lastDeployment ? (
<>
<div className="flex items-center justify-between">
<DeployBadge status={snap.lastDeployment.status} />
<span className="text-xs text-muted-foreground">{timeAgo(snap.lastDeployment.timestamp)}</span>
</div>
{snap.lastDeployment.url && (
<a
href={snap.lastDeployment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
{snap.lastDeployment.url}
</a>
)}
</>
) : (
<div className="text-center py-4 space-y-3">
<p className="text-sm text-muted-foreground">No deployments yet</p>
<Button size="sm" variant="outline" asChild>
<Link href={`/${workspace}/project/${projectId}/deployment`}>
Set up deployment
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Open PRs ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
Pull Requests
{(snap?.openPRs?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openPRs!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openPRs?.length ? (
<ul className="space-y-2">
{snap.openPRs.map(pr => (
<li key={pr.number}>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{pr.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{pr.title}</p>
<p className="text-xs text-muted-foreground">{pr.from} {pr.into}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open pull requests</p>
)}
</CardContent>
</Card>
{/* ── Open Issues ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CircleDot className="h-4 w-4" />
Issues
{(snap?.openIssues?.length ?? 0) > 0 && (
<Badge variant="secondary" className="ml-auto">{snap!.openIssues!.length} open</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{snap?.openIssues?.length ? (
<ul className="space-y-2">
{snap.openIssues.map(issue => (
<li key={issue.number}>
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
>
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{issue.number}</span>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{issue.title}</p>
{issue.labels?.length ? (
<div className="flex gap-1 flex-wrap mt-0.5">
{issue.labels.map(l => (
<span key={l} className="text-[10px] px-1.5 py-0.5 bg-muted rounded-full">{l}</span>
))}
</div>
) : null}
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No open issues</p>
)}
</CardContent>
</Card>
</div>
{/* ── Recent Commits ── */}
{snap?.recentCommits && snap.recentCommits.length > 1 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<GitCommit className="h-4 w-4" />
Recent Commits
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{snap.recentCommits.map((c, i) => (
<li key={i} className="flex items-center gap-3 text-sm py-1.5 border-b last:border-0">
<span className="font-mono text-xs text-muted-foreground w-16 shrink-0">{c.sha.slice(0, 8)}</span>
<span className="flex-1 line-clamp-1">{c.message}</span>
<span className="text-xs text-muted-foreground shrink-0">{c.author ?? ""}</span>
<span className="text-xs text-muted-foreground shrink-0 w-16 text-right">{timeAgo(c.timestamp)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* ── Resources ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Database className="h-4 w-4" />
Resources
</CardTitle>
<CardDescription className="text-xs">Databases and services linked to this project</CardDescription>
</CardHeader>
<CardContent>
{project.coolifyDbUuid ? (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Database provisioned</span>
<Badge variant="outline" className="text-xs ml-auto">{project.coolifyDbUuid}</Badge>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">No databases provisioned yet</p>
<Button size="sm" variant="outline">
<Database className="h-3.5 w-3.5 mr-1.5" />
Add Database
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Context snapshot freshness ── */}
{snap?.updatedAt && (
<p className="text-xs text-muted-foreground text-right">
Context updated {timeAgo(snap.updatedAt)} via webhooks
</p>
)}
</div>
);
}