- 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
525 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|