diff --git a/app/(justine)/pricing/page.tsx b/app/(justine)/pricing/page.tsx index 6b2883d..9c6d422 100644 --- a/app/(justine)/pricing/page.tsx +++ b/app/(justine)/pricing/page.tsx @@ -58,7 +58,7 @@ export default function PricingPage() { {/* Pro Tier */} - +
Popular
diff --git a/app/(justine)/stories/page.tsx b/app/(justine)/stories/page.tsx new file mode 100644 index 0000000..975ee06 --- /dev/null +++ b/app/(justine)/stories/page.tsx @@ -0,0 +1 @@ +export { default } from "../features/page"; diff --git a/app/[workspace]/project/[projectId]/(home)/layout.tsx b/app/[workspace]/project/[projectId]/(home)/layout.tsx new file mode 100644 index 0000000..f9a34ab --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/layout.tsx @@ -0,0 +1,34 @@ +"use client"; + +/** + * Project home scaffold. + * + * Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left, + * cream main area on the right. Used only for the project home page + * (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group + * with the ProjectShell tab nav instead. + */ + +import { ReactNode } from "react"; +import { useParams } from "next/navigation"; +import { Toaster } from "sonner"; +import { VIBNSidebar } from "@/components/layout/vibn-sidebar"; +import { ProjectAssociationPrompt } from "@/components/project-association-prompt"; + +export default function ProjectHomeLayout({ children }: { children: ReactNode }) { + const params = useParams(); + const workspace = params.workspace as string; + + return ( + <> +
+ +
+ {children} +
+
+ + + + ); +} diff --git a/app/[workspace]/project/[projectId]/(home)/page.tsx b/app/[workspace]/project/[projectId]/(home)/page.tsx new file mode 100644 index 0000000..2ce293e --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/page.tsx @@ -0,0 +1,696 @@ +"use client"; + +/** + * Project home page. + * + * Sits between the projects list and the AI interview. Gives users two + * simplified entry tiles — Code (their Gitea repo) and Infrastructure + * (their Coolify deployment) — plus a quiet "Continue setup" link if + * the discovery interview isn't done. + * + * Styled to match the production "ink & parchment" design: + * Newsreader serif headings, Outfit sans body, warm beige borders, + * solid black CTAs. No indigo. No gradients. + */ + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { isClientDevProjectBypass } from "@/lib/dev-bypass"; +import { + ArrowRight, + Code2, + ExternalLink, + FileText, + Folder, + Loader2, + Rocket, +} from "lucide-react"; + +// ── Design tokens (mirrors the prod ink & parchment palette) ───────── +const INK = { + fontSerif: '"Newsreader", "Lora", Georgia, serif', + fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', + fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace', + ink: "#1a1a1a", + ink2: "#2c2c2a", + mid: "#5f5e5a", + muted: "#a09a90", + stone: "#b5b0a6", + border: "#e8e4dc", + borderHover: "#d0ccc4", + cardBg: "#fff", + pageBg: "#f7f4ee", + shadow: "0 1px 2px #1a1a1a05", + shadowHover: "0 2px 8px #1a1a1a0a", + iconWrapBg: "#1a1a1a08", +} as const; + +interface ProjectSummary { + id: string; + productName?: string; + name?: string; + productVision?: string; + description?: string; + giteaRepo?: string; + giteaRepoUrl?: string; + stage?: "discovery" | "architecture" | "building" | "active"; + creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; + discoveryPhase?: number; + progress?: number; +} + +interface FileTreeItem { + name: string; + path: string; + type: "file" | "dir"; +} + +interface PreviewApp { + name: string; + url: string | null; + status: string; +} + +export default function ProjectHomePage() { + const params = useParams(); + const workspace = params.workspace as string; + const projectId = params.projectId as string; + const { status: authStatus } = useSession(); + + const [project, setProject] = useState(null); + const [projectLoading, setProjectLoading] = useState(true); + + const [files, setFiles] = useState(null); + const [filesLoading, setFilesLoading] = useState(true); + + const [apps, setApps] = useState([]); + const [appsLoading, setAppsLoading] = useState(true); + + const ready = useMemo( + () => isClientDevProjectBypass() || authStatus === "authenticated", + [authStatus] + ); + + useEffect(() => { + if (!ready) { + if (authStatus === "unauthenticated") setProjectLoading(false); + return; + } + fetch(`/api/projects/${projectId}`, { credentials: "include" }) + .then(r => r.json()) + .then(d => setProject(d.project ?? null)) + .catch(() => {}) + .finally(() => setProjectLoading(false)); + }, [ready, authStatus, projectId]); + + useEffect(() => { + if (!ready) return; + fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" }) + .then(r => (r.ok ? r.json() : null)) + .then(d => { + if (d?.type === "dir" && Array.isArray(d.items)) { + setFiles(d.items as FileTreeItem[]); + } else { + setFiles([]); + } + }) + .catch(() => setFiles([])) + .finally(() => setFilesLoading(false)); + }, [ready, projectId]); + + useEffect(() => { + if (!ready) return; + fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" }) + .then(r => (r.ok ? r.json() : null)) + .then(d => setApps(Array.isArray(d?.apps) ? d.apps : [])) + .catch(() => {}) + .finally(() => setAppsLoading(false)); + }, [ready, projectId]); + + const projectName = project?.productName || project?.name || "Untitled project"; + const projectDesc = project?.productVision || project?.description; + const stage = project?.stage ?? "discovery"; + const interviewIncomplete = stage === "discovery"; + const liveApp = apps.find(a => a.url) ?? apps[0] ?? null; + + if (projectLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!project) { + return ( +
+
+ Project not found. +
+
+ ); + } + + return ( +
+
+ {/* ── Hero ─────────────────────────────────────────────── */} +
+
+
Project
+

{projectName}

+ {projectDesc &&

{projectDesc}

} +
+ +
+ + {/* ── Continue setup link (quiet, only when in discovery) ── */} + {interviewIncomplete && ( + +
+ +
+
Continue setup
+
+ Pick up the AI interview where you left off. +
+
+
+ + + )} + + {/* ── Two big tiles ────────────────────────────────────── */} +
+ + +
+
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// Tiles +// ────────────────────────────────────────────────────────────────────── + +function CodeTile({ + workspace, + projectId, + files, + loading, + giteaRepo, +}: { + workspace: string; + projectId: string; + files: FileTreeItem[] | null; + loading: boolean; + giteaRepo?: string; +}) { + const items = files ?? []; + const dirCount = items.filter(i => i.type === "dir").length; + const fileCount = items.filter(i => i.type === "file").length; + const previewItems = items.slice(0, 6); + + return ( + +
+
+ + + +
+

Code

+

What the AI is building, file by file.

+
+ +
+ +
+ {loading ? ( + + ) : items.length === 0 ? ( + } + title="No files yet" + subtitle={ + giteaRepo + ? "Your repository is empty. The AI will commit the first files when you start building." + : "This project doesn't have a repository yet." + } + /> + ) : ( + <> +
+ + +
+
    + {previewItems.map(item => ( +
  • + + {item.type === "dir" ? ( + + ) : ( + + )} + + {item.name} + + {item.type === "dir" ? "folder" : ext(item.name)} + +
  • + ))} +
+ {items.length > previewItems.length && ( +
+ +{items.length - previewItems.length} more +
+ )} + + )} +
+
+ + ); +} + +function InfraTile({ + workspace, + projectId, + app, + loading, +}: { + workspace: string; + projectId: string; + app: PreviewApp | null; + loading: boolean; +}) { + const status = app?.status?.toLowerCase() ?? "unknown"; + const isLive = !!app?.url && (status.includes("running") || status.includes("healthy")); + const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting"); + + return ( + +
+
+ + + +
+

Infrastructure

+

What's live and how it's running.

+
+ +
+ +
+ {loading ? ( + + ) : !app ? ( + } + title="Nothing is live yet" + subtitle="The AI will deploy your project here once the build is ready." + /> + ) : ( + <> +
+ + +
+ {app.url ? ( + + ) : ( +
+ Status + {statusFriendly(status)} +
+ )} + + )} +
+
+ + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Small bits +// ────────────────────────────────────────────────────────────────────── + +function StagePill({ stage }: { stage: string }) { + const map: Record = { + discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" }, + architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" }, + building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" }, + active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" }, + }; + const s = map[stage] ?? map.discovery; + return ( + + + {s.label} + + ); +} + +function StatusBlock({ color, label }: { color: string; label: string }) { + return ( +
+ Status + + + {label} + +
+ ); +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function TileLoader({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function TileEmpty({ + icon, + title, + subtitle, +}: { + icon: React.ReactNode; + title: string; + subtitle: string; +}) { + return ( +
+ {icon} +
{title}
+
+ {subtitle} +
+
+ ); +} + +function statusFriendly(status: string): string { + if (!status || status === "unknown") return "Unknown"; + return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase()); +} + +function ext(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1) : "file"; +} + +function shortUrl(url: string): string { + try { + const u = new URL(url); + return u.host + (u.pathname === "/" ? "" : u.pathname); + } catch { + return url; + } +} + +function hoverEnter(e: React.MouseEvent) { + const el = e.currentTarget; + el.style.borderColor = INK.borderHover; + el.style.boxShadow = INK.shadowHover; +} +function hoverLeave(e: React.MouseEvent) { + const el = e.currentTarget; + el.style.borderColor = INK.border; + el.style.boxShadow = INK.shadow; +} + +// ────────────────────────────────────────────────────────────────────── +// Styles +// ────────────────────────────────────────────────────────────────────── + +const pageWrap: React.CSSProperties = { + flex: 1, + minHeight: 0, + overflow: "auto", + background: INK.pageBg, + fontFamily: INK.fontSans, +}; + +const pageInner: React.CSSProperties = { + maxWidth: 900, + margin: "0 auto", + padding: "44px 52px 64px", + display: "flex", + flexDirection: "column", + gap: 28, +}; + +const centeredFiller: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "center", + height: "100%", padding: 64, +}; + +const heroStyle: React.CSSProperties = { + display: "flex", alignItems: "flex-start", justifyContent: "space-between", + gap: 24, +}; + +const eyebrow: React.CSSProperties = { + fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", + textTransform: "uppercase", color: INK.muted, + fontFamily: INK.fontSans, marginBottom: 8, +}; + +const heroTitle: React.CSSProperties = { + fontFamily: INK.fontSerif, + fontSize: "1.9rem", fontWeight: 400, + color: INK.ink, letterSpacing: "-0.03em", + lineHeight: 1.15, margin: 0, +}; + +const heroDesc: React.CSSProperties = { + fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620, + lineHeight: 1.6, fontFamily: INK.fontSans, +}; + +const continueRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, + background: INK.cardBg, border: `1px solid ${INK.border}`, + borderRadius: 10, padding: "14px 18px", + textDecoration: "none", color: "inherit", + boxShadow: INK.shadow, + fontFamily: INK.fontSans, + transition: "border-color 0.15s, box-shadow 0.15s", +}; + +const continueDot: React.CSSProperties = { + width: 7, height: 7, borderRadius: "50%", + background: "#d4a04a", flexShrink: 0, +}; + +const continueTitle: React.CSSProperties = { + fontSize: 13, fontWeight: 600, color: INK.ink, +}; + +const continueSub: React.CSSProperties = { + fontSize: 12, color: INK.muted, marginTop: 2, +}; + +const tileGrid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", + gap: 14, +}; + +const tileLink: React.CSSProperties = { + textDecoration: "none", color: "inherit", +}; + +const tileCard: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: 22, + display: "flex", flexDirection: "column", gap: 18, + minHeight: 280, + boxShadow: INK.shadow, + transition: "border-color 0.15s, box-shadow 0.15s", + fontFamily: INK.fontSans, +}; + +const tileHeader: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 12, +}; + +const tileIconWrap: React.CSSProperties = { + width: 32, height: 32, borderRadius: 8, + background: INK.iconWrapBg, color: INK.ink, + display: "flex", alignItems: "center", justifyContent: "center", + flexShrink: 0, +}; + +const tileTitle: React.CSSProperties = { + fontFamily: INK.fontSerif, + fontSize: "1.05rem", fontWeight: 400, + color: INK.ink, letterSpacing: "-0.02em", + margin: 0, lineHeight: 1.2, +}; + +const tileSubtitle: React.CSSProperties = { + fontSize: 12, color: INK.muted, marginTop: 3, + fontFamily: INK.fontSans, +}; + +const tileBody: React.CSSProperties = { + display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0, +}; + +const tileMetaRow: React.CSSProperties = { + display: "flex", gap: 28, +}; + +const metricLabel: React.CSSProperties = { + fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em", + textTransform: "uppercase", color: INK.muted, + fontFamily: INK.fontSans, +}; + +const fileList: React.CSSProperties = { + listStyle: "none", padding: 0, margin: 0, + display: "flex", flexDirection: "column", + border: `1px solid ${INK.border}`, borderRadius: 8, + overflow: "hidden", + background: "#fdfcfa", +}; + +const fileRow: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + padding: "8px 12px", + borderTop: `1px solid ${INK.border}`, + fontSize: 12.5, color: INK.ink, +}; + +const fileIconWrap: React.CSSProperties = { + color: INK.stone, display: "flex", alignItems: "center", +}; + +const fileName: React.CSSProperties = { + flex: 1, minWidth: 0, overflow: "hidden", + textOverflow: "ellipsis", whiteSpace: "nowrap", + fontFamily: INK.fontMono, fontSize: 12, +}; + +const fileType: React.CSSProperties = { + fontSize: 10, color: INK.stone, fontWeight: 500, + textTransform: "uppercase", letterSpacing: "0.08em", + flexShrink: 0, fontFamily: INK.fontSans, +}; + +const tileMore: React.CSSProperties = { + fontSize: 11.5, color: INK.muted, paddingLeft: 4, + fontFamily: INK.fontSans, +}; + +const liveUrlRow: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + padding: "10px 12px", + background: "#fdfcfa", + border: `1px solid ${INK.border}`, + borderRadius: 8, +}; + +const liveUrlLabel: React.CSSProperties = { + fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em", + textTransform: "uppercase", color: INK.muted, + flexShrink: 0, fontFamily: INK.fontSans, +}; + +const liveUrlValue: React.CSSProperties = { + flex: 1, minWidth: 0, + fontSize: 12, color: INK.ink, fontWeight: 500, + overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", + fontFamily: INK.fontMono, +}; + +const liveUrlOpen: React.CSSProperties = { + width: 24, height: 24, borderRadius: 6, + display: "flex", alignItems: "center", justifyContent: "center", + color: INK.ink, background: INK.cardBg, + border: `1px solid ${INK.border}`, flexShrink: 0, + textDecoration: "none", +}; diff --git a/app/[workspace]/project/[projectId]/(workspace)/build/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/build/page.tsx index ab6f860..e97b88e 100644 --- a/app/[workspace]/project/[projectId]/(workspace)/build/page.tsx +++ b/app/[workspace]/project/[projectId]/(workspace)/build/page.tsx @@ -3,7 +3,15 @@ import { Suspense, useState, useEffect, useCallback, useRef } from "react"; import { useParams, useSearchParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; +import { isClientDevProjectBypass } from "@/lib/dev-bypass"; import Link from "next/link"; +import { JM } from "@/components/project-creation/modal-theme"; +import { AtlasChat } from "@/components/AtlasChat"; +import { PRD_PLAN_SECTIONS } from "@/lib/prd-sections"; +import { + type ChatContextRef, + contextRefKey, +} from "@/lib/chat-context-refs"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -17,15 +25,6 @@ interface TreeNode { // ── 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 = { webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel", }; @@ -33,6 +32,74 @@ const SURFACE_ICONS: Record = { webapp: "◈", marketing: "◌", admin: "◫", }; +/** Growth page–style left rail: cream panel, icon + label rows */ +const BUILD_LEFT_BG = "#faf8f5"; +const BUILD_LEFT_BORDER = "#e8e4dc"; + +const BUILD_NAV_GROUP: React.CSSProperties = { + fontSize: "0.6rem", + fontWeight: 700, + color: "#b5b0a6", + letterSpacing: "0.09em", + textTransform: "uppercase", + padding: "14px 12px 6px", + fontFamily: JM.fontSans, +}; + +const BUILD_PRIMARY = [ + { id: "chat", label: "Chat", icon: "◆" }, + { id: "code", label: "Code", icon: "◇" }, + { id: "layouts", label: "Layouts", icon: "◈" }, + { id: "tasks", label: "Agent", icon: "◎" }, + { id: "preview", label: "Preview", icon: "▢" }, +] as const; + +function BuildGrowthNavRow({ + icon, + label, + active, + onClick, +}: { + icon: string; + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + // ── Language / syntax helpers ───────────────────────────────────────────────── function langFromName(name: string): string { @@ -103,28 +170,11 @@ function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: { // ── Left nav shared styles ──────────────────────────────────────────────────── const NAV_GROUP_LABEL: React.CSSProperties = { - fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", + fontSize: "0.6rem", fontWeight: 700, color: JM.muted, letterSpacing: "0.09em", textTransform: "uppercase", - padding: "12px 12px 5px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", + padding: "12px 12px 5px", fontFamily: JM.fontSans, }; -function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) { - return ( - - ); -} - // ── Placeholder panel ───────────────────────────────────────────────────────── function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) { @@ -140,30 +190,6 @@ function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: ); } -// ── Infra content ───────────────────────────────────────────────────────────── - -function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) { - const base = `/${workspace}/project/${projectId}/infrastructure`; - const descriptions: Record = { - 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 ( -
-
-
{tab}
- Open full view → -
- {d && } -
- ); -} - // ── Layouts content ─────────────────────────────────────────────────────────── function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: { @@ -715,13 +741,6 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName > Approve & commit - - Open in Theia → - )} @@ -926,7 +945,7 @@ function TerminalPanel({ appName }: { appName: string }) {
{appName - ? `Live shell into the ${appName} container via xterm.js + Theia PTY — coming in Phase 4.` + ? `Live shell into the ${appName} container — coming in Phase 4.` : "Select an app from the left, then open a live shell into its container."}
@@ -956,7 +975,8 @@ function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: { }, [projectId]); useEffect(() => { - if (!rootPath || status !== "authenticated") return; + if (!rootPath) return; + if (!isClientDevProjectBypass() && status !== "authenticated") return; setTree([]); setTreeLoading(true); fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false)); }, [rootPath, status, fetchDir]); @@ -1046,21 +1066,6 @@ interface PreviewApp { name: string; url: string | null; status: string; } // ── PRD Content ─────────────────────────────────────────────────────────────── -const PRD_SECTIONS = [ - { id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" }, - { id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" }, - { id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" }, - { id: "users_personas", label: "Users & Personas", phaseId: "users_personas" }, - { id: "user_flows", label: "User Flows", phaseId: "users_personas" }, - { id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" }, - { id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" }, - { id: "business_model", label: "Business Model", phaseId: "business_model" }, - { id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" }, - { id: "non_functional", label: "Non-Functional Reqs", phaseId: null }, - { id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" }, - { id: "open_questions", label: "Open Questions", phaseId: "risks_questions" }, -]; - interface SavedPhase { phase: string; title: string; summary: string; data: Record; saved_at: string; } function PrdContent({ projectId }: { projectId: string }) { @@ -1086,7 +1091,7 @@ function PrdContent({ projectId }: { projectId: string }) { const phaseMap = new Map(savedPhases.map(p => [p.phase, p])); const savedPhaseIds = new Set(savedPhases.map(p => p.phase)); - const sections = PRD_SECTIONS.map(s => ({ + const sections = PRD_PLAN_SECTIONS.map(s => ({ ...s, savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null, isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false, @@ -1272,10 +1277,11 @@ function BuildHubInner() { const projectId = params.projectId as string; const workspace = params.workspace as string; - const section = searchParams.get("section") ?? "code"; + const section = searchParams.get("section") ?? "chat"; + const tasksSubTabRaw = searchParams.get("tab") ?? "tasks"; + const tasksSubTab = tasksSubTabRaw === "prd" ? "requirements" : tasksSubTabRaw; 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([]); @@ -1292,6 +1298,32 @@ function BuildHubInner() { const [fileLoading, setFileLoading] = useState(false); const [fileName, setFileName] = useState(null); + const [chatContextRefs, setChatContextRefs] = useState([]); + + const addAppToChat = useCallback((app: AppEntry) => { + setChatContextRefs(prev => { + const next: ChatContextRef = { kind: "app", label: app.name, path: app.path }; + const k = contextRefKey(next); + if (prev.some(r => contextRefKey(r) === k)) return prev; + return [...prev, next]; + }); + }, []); + + const removeChatContextRef = useCallback((key: string) => { + setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key)); + }, []); + + useEffect(() => { + if (searchParams.get("section") !== "infrastructure") return; + const t = searchParams.get("tab") ?? "builds"; + router.replace(`/${workspace}/project/${projectId}/run?tab=${encodeURIComponent(t)}`, { scroll: false }); + }, [searchParams, workspace, projectId, router]); + + useEffect(() => { + if (searchParams.get("section") !== "mvp") return; + router.replace(`/${workspace}/project/${projectId}/mvp-setup/launch`, { scroll: false }); + }, [searchParams, workspace, projectId, router]); + useEffect(() => { fetch(`/api/projects/${projectId}/preview-url`) .then(r => r.json()) @@ -1336,82 +1368,114 @@ function BuildHubInner() { router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false }); }; + const workspaceAppActive = (app: AppEntry) => { + if (section === "chat") { + return chatContextRefs.some(r => r.kind === "app" && r.path === app.path); + } + if (section === "code" || (section === "tasks" && tasksSubTab !== "requirements")) { + return activeApp === app.name; + } + return false; + }; + + const onWorkspaceApp = (app: AppEntry) => { + if (section === "chat") addAppToChat(app); + else if (section === "code") navigate({ section: "code", app: app.name, root: app.path }); + else if (section === "tasks" && tasksSubTab !== "requirements") { + navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path }); + } + else navigate({ section: "code", app: app.name, root: app.path }); + }; + return ( -
+
+ {/* Growth-style left rail */} +
+
Build
+ {BUILD_PRIMARY.map(p => ( + { + if (p.id === "tasks") navigate({ section: "tasks", tab: "tasks" }); + else navigate({ section: p.id }); + }} + /> + ))} - {/* ── Build content ── */} -
- - {/* Inner nav — contextual items driven by top-bar tool icon */} -
- - {/* Code: app list + file tree */} - {section === "code" && ( -
-
-
Apps
- {apps.length > 0 ? apps.map(app => ( - navigate({ section: "code", app: app.name, root: app.path })} - /> - )) : ( -
No apps yet
- )} -
- {activeApp && activeRoot && ( -
-
- Files - {activeApp} -
- -
- )} +
+ {section === "chat" && ( +
+ Attach monorepo apps from Workspace below. Live preview stays on the right when deployed. +
+ )} + + {section === "code" && activeApp && activeRoot && ( +
+
Files
+
+ {activeApp} +
+
+ +
)} - {/* Layouts: surface list */} {section === "layouts" && ( -
+
Surfaces
{surfaces.length > 0 ? surfaces.map(s => ( - { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }} /> )) : ( -
Not configured
+
Not configured
)}
)} - {/* Infrastructure: item list */} - {section === "infrastructure" && ( -
-
Infrastructure
- {INFRA_ITEMS.map(item => ( - navigate({ section: "infrastructure", tab: item.id })} - /> - ))} -
- )} - - {/* Tasks: sub-nav + app list */} {section === "tasks" && ( -
- {/* Tasks | PRD sub-nav */} -
- {[{ id: "tasks", label: "Tasks" }, { id: "prd", label: "PRD" }].map(item => { - const isActive = (searchParams.get("tab") ?? "tasks") === item.id; +
+
+ {[{ id: "tasks", label: "Runs" }, { id: "requirements", label: "Requirements" }].map(item => { + const isActive = tasksSubTab === item.id; return ( -
- {/* App list (only in tasks tab) */} - {(searchParams.get("tab") ?? "tasks") !== "prd" && ( - <> -
Apps
- {apps.length > 0 ? apps.map(app => ( - navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path })} - /> - )) : ( -
No apps yet
- )} - - )}
)} - {/* Preview: deployed apps list */} {section === "preview" && ( -
-
Apps
+
+
Deployed
{previewApps.length > 0 ? previewApps.map(app => ( - setActivePreviewApp(app)} /> )) : ( -
No deployments yet
+
No deployments yet
)}
)}
- {/* Main content panel */} +
+ {apps.length > 0 && ( + <> +
Workspace
+ {apps.map(app => ( + onWorkspaceApp(app)} + /> + ))} + + )} + {section === "chat" && previewApps.length > 0 && ( + <> +
Live
+ {previewApps.map(app => ( + setActivePreviewApp(app)} + /> + ))} + + )} + + Open Tasks → + +
+
+ + {/* Main content */} +
+ {section === "chat" && ( +
+
+ +
+
+ +
+
+ )} {section === "code" && (
{ setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} /> )} - {section === "infrastructure" && ( - - )} - {section === "tasks" && (searchParams.get("tab") ?? "tasks") !== "prd" && ( + {section === "tasks" && tasksSubTab !== "requirements" && ( )} - {section === "tasks" && searchParams.get("tab") === "prd" && ( + {section === "tasks" && tasksSubTab === "requirements" && ( )} {section === "preview" && ( @@ -1492,7 +1619,7 @@ function BuildHubInner() { export default function BuildPage() { return ( - Loading…
}> + Loading…
}> ); diff --git a/app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx index 5290aa4..86cce2f 100644 --- a/app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx +++ b/app/[workspace]/project/[projectId]/(workspace)/deployment/page.tsx @@ -10,7 +10,6 @@ interface Project { status?: string; giteaRepoUrl?: string; giteaRepo?: string; - theiaWorkspaceUrl?: string; coolifyDeployUrl?: string; customDomain?: string; prd?: string; @@ -70,7 +69,7 @@ export default function DeploymentPage() { ); } - const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl); + const hasDeploy = Boolean(project?.coolifyDeployUrl); const hasRepo = Boolean(project?.giteaRepoUrl); const hasPRD = Boolean(project?.prd); diff --git a/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx index 46e30b8..fffb997 100644 --- a/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx +++ b/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx @@ -1,353 +1,7 @@ -"use client"; - -import { Suspense, useState, useEffect } from "react"; -import { useParams, useSearchParams, useRouter } from "next/navigation"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface InfraApp { - name: string; - domain?: string | null; - coolifyServiceUuid?: string | null; -} - -interface ProjectData { - giteaRepo?: string; - giteaRepoUrl?: string; - apps?: InfraApp[]; -} - -// ── Tab definitions ─────────────────────────────────────────────────────────── - -const TABS = [ - { 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: "≈" }, -] as const; - -type TabId = typeof TABS[number]["id"]; - -// ── Shared empty state ──────────────────────────────────────────────────────── - -function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) { - return ( -
-
- {icon} -
-
-
{title}
-
{description}
-
-
- Coming soon -
-
- ); -} - -// ── Builds tab ──────────────────────────────────────────────────────────────── - -function BuildsTab({ project }: { project: ProjectData | null }) { - const apps = project?.apps ?? []; - if (apps.length === 0) { - return ( - - ); - } - return ( -
-
- Deployed Apps -
-
- {apps.map(app => ( -
-
- -
-
{app.name}
- {app.domain && ( -
{app.domain}
- )} -
-
-
- - Running -
-
- ))} -
-
- ); -} - -// ── Databases tab ───────────────────────────────────────────────────────────── - -function DatabasesTab() { - return ( - - ); -} - -// ── Services tab ────────────────────────────────────────────────────────────── - -function ServicesTab() { - return ( - - ); -} - -// ── Environment tab ─────────────────────────────────────────────────────────── - -function EnvironmentTab({ project }: { project: ProjectData | null }) { - return ( -
-
- Environment Variables & Secrets -
-
- {/* Header row */} -
- KeyValue -
- {/* Placeholder rows */} - {["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => ( -
- {k} - •••••••• - -
- ))} -
- -
-
-
- Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs. -
-
- ); -} - -// ── Domains tab ─────────────────────────────────────────────────────────────── - -function DomainsTab({ project }: { project: ProjectData | null }) { - const apps = (project?.apps ?? []).filter(a => a.domain); - return ( -
-
- Domains & SSL -
- {apps.length > 0 ? ( -
- {apps.map(app => ( -
-
-
- {app.domain} -
-
{app.name}
-
-
- - SSL active -
-
- ))} -
- ) : ( -
-
No custom domains configured
-
Deploy an app first, then point a domain here.
-
- )} - -
- ); -} - -// ── Logs tab ────────────────────────────────────────────────────────────────── - -function LogsTab({ project }: { project: ProjectData | null }) { - const apps = project?.apps ?? []; - if (apps.length === 0) { - return ( - - ); - } - return ( -
-
- Runtime Logs -
-
-
{"# Logs will stream here once connected to Coolify"}
-
{"→ Select a service to tail its log output"}
-
-
- ); -} - -// ── Inner page ──────────────────────────────────────────────────────────────── - -function InfrastructurePageInner() { - const params = useParams(); - const searchParams = useSearchParams(); - const router = useRouter(); - const projectId = params.projectId as string; - const workspace = params.workspace as string; - - const activeTab = (searchParams.get("tab") ?? "builds") as TabId; - const [project, setProject] = useState(null); - - useEffect(() => { - fetch(`/api/projects/${projectId}/apps`) - .then(r => r.json()) - .then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl })) - .catch(() => {}); - }, [projectId]); - - const setTab = (id: TabId) => { - router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false }); - }; - - return ( -
- - {/* ── Left sub-nav ── */} -
-
- Infrastructure -
- {TABS.map(tab => { - const active = activeTab === tab.id; - return ( - - ); - })} -
- - {/* ── Content ── */} -
- {activeTab === "builds" && } - {activeTab === "databases" && } - {activeTab === "services" && } - {activeTab === "environment" && } - {activeTab === "domains" && } - {activeTab === "logs" && } -
-
- ); -} - -// ── Export ──────────────────────────────────────────────────────────────────── +import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel"; export default function InfrastructurePage() { return ( - Loading…
}> - - + ); } diff --git a/app/[workspace]/project/[projectId]/(workspace)/layout.tsx b/app/[workspace]/project/[projectId]/(workspace)/layout.tsx new file mode 100644 index 0000000..89e227e --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/layout.tsx @@ -0,0 +1,81 @@ +import { Plus_Jakarta_Sans } from "next/font/google"; +import { ProjectShell } from "@/components/layout/project-shell"; +import { query } from "@/lib/db-postgres"; + +const plusJakarta = Plus_Jakarta_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + variable: "--font-justine-jakarta", +}); + +interface ProjectData { + name: string; + description?: string; + status?: string; + progress?: number; + discoveryPhase?: number; + capturedData?: Record; + createdAt?: string; + updatedAt?: string; + featureCount?: number; + creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; +} + +async function getProjectData(projectId: string): Promise { + try { + const rows = await query<{ data: any; created_at?: string; updated_at?: string }>( + `SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + if (rows.length > 0) { + const { data, created_at, updated_at } = rows[0]; + return { + name: data?.productName || data?.name || "Project", + description: data?.productVision || data?.description, + status: data?.status, + progress: data?.progress ?? 0, + discoveryPhase: data?.discoveryPhase ?? 0, + capturedData: data?.capturedData ?? {}, + createdAt: created_at, + updatedAt: updated_at, + featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0), + creationMode: data?.creationMode ?? "fresh", + }; + } + } catch (error) { + console.error("Error fetching project:", error); + } + return { name: "Project" }; +} + +export default async function ProjectLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + const project = await getProjectData(projectId); + + return ( +
+ + {children} + +
+ ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/architect/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/architect/page.tsx new file mode 100644 index 0000000..e556218 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/architect/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder"; + +export default function MvpSetupArchitectPage() { + const { workspace, projectId } = useParams() as { workspace: string; projectId: string }; + const base = `/${workspace}/project/${projectId}`; + return ( + + ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/describe/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/describe/page.tsx new file mode 100644 index 0000000..2f50dc5 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/describe/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { MvpSetupDescribeView } from "@/components/project-main/MvpSetupDescribeView"; + +export default function MvpSetupDescribePage() { + const params = useParams(); + const projectId = params.projectId as string; + const workspace = params.workspace as string; + return ; +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/design/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/design/page.tsx new file mode 100644 index 0000000..202fc80 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/design/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder"; + +export default function MvpSetupDesignPage() { + const { workspace, projectId } = useParams() as { workspace: string; projectId: string }; + const base = `/${workspace}/project/${projectId}`; + return ( + + ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/layout.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/layout.tsx new file mode 100644 index 0000000..0225a34 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/layout.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { MvpSetupLayoutClient } from "@/components/project-main/MvpSetupLayoutClient"; + +export default async function MvpSetupWizardLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + return ( + + {children} + + ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/website/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/website/page.tsx new file mode 100644 index 0000000..df38b10 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/(wizard)/website/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder"; + +export default function MvpSetupWebsitePage() { + const { workspace, projectId } = useParams() as { workspace: string; projectId: string }; + const base = `/${workspace}/project/${projectId}`; + return ( + + ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/launch/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/launch/page.tsx new file mode 100644 index 0000000..9e9a7d1 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/launch/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { BuildMvpJustineV2 } from "@/components/project-main/BuildMvpJustineV2"; +import { JM } from "@/components/project-creation/modal-theme"; + +interface SurfaceEntry { + id: string; + lockedTheme?: string; +} + +export default function MvpSetupLaunchPage() { + const { workspace, projectId } = useParams() as { workspace: string; projectId: string }; + const router = useRouter(); + const [productName, setProductName] = useState("Your product"); + const [giteaRepo, setGiteaRepo] = useState(); + const [surfaces, setSurfaces] = useState([]); + + useEffect(() => { + fetch(`/api/projects/${projectId}`) + .then(r => r.json()) + .then(d => { + const p = d.project; + if (p) { + setProductName(p.productName || p.name || "Your product"); + setGiteaRepo(p.giteaRepo); + } + }) + .catch(() => {}); + + fetch(`/api/projects/${projectId}/design-surfaces`) + .then(r => r.json()) + .then(d => { + const ids: string[] = d.surfaces ?? []; + const themes: Record = d.surfaceThemes ?? {}; + setSurfaces(ids.map(id => ({ id, lockedTheme: themes[id] }))); + }) + .catch(() => {}); + }, [projectId]); + + const webappSurface = surfaces.find(s => s.id === "webapp"); + const marketingSurface = surfaces.find(s => s.id === "marketing"); + + return ( +
+ { + router.push(`/${workspace}/project/${projectId}/build?section=preview`, { scroll: false }); + }} + /> +
+ ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/layout.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/layout.tsx new file mode 100644 index 0000000..9a3cbe9 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +/** Root: no sidebar — launch step uses full Justine chrome; wizard steps use (wizard)/layout. */ +export default function MvpSetupRootLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/page.tsx new file mode 100644 index 0000000..c537666 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/mvp-setup/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function MvpSetupIndexPage({ + params, +}: { + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/mvp-setup/describe`); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx index c667095..de690b6 100644 --- a/app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/(workspace)/overview/page.tsx @@ -3,7 +3,9 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { useSession } from "next-auth/react"; +import { isClientDevProjectBypass } from "@/lib/dev-bypass"; import { Loader2 } from "lucide-react"; +import { JM } from "@/components/project-creation/modal-theme"; import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain"; import { ChatImportMain } from "@/components/project-main/ChatImportMain"; import { CodeImportMain } from "@/components/project-main/CodeImportMain"; @@ -35,10 +37,12 @@ export default function ProjectOverviewPage() { const [loading, setLoading] = useState(true); useEffect(() => { - if (authStatus !== "authenticated") { + const bypass = isClientDevProjectBypass(); + if (!bypass && authStatus !== "authenticated") { if (authStatus === "unauthenticated") setLoading(false); return; } + if (!bypass && authStatus === "loading") return; fetch(`/api/projects/${projectId}`) .then(r => r.json()) .then(d => setProject(d.project)) @@ -48,15 +52,23 @@ export default function ProjectOverviewPage() { if (loading) { return ( -
- +
+
); } if (!project) { return ( -
+
Project not found.
); diff --git a/app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx index 4b26990..72641e1 100644 --- a/app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx +++ b/app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx @@ -1,459 +1,11 @@ -"use client"; +import { redirect } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; - -// Maps each PRD section to the discovery phase that populates it -const PRD_SECTIONS = [ - { id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" }, - { id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" }, - { id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" }, - { id: "users_personas", label: "Users & Personas", phaseId: "users_personas" }, - { id: "user_flows", label: "User Flows", phaseId: "users_personas" }, - { id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" }, - { id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" }, - { id: "business_model", label: "Business Model", phaseId: "business_model" }, - { id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" }, - { id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" }, - { id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" }, - { id: "open_questions", label: "Open Questions", phaseId: "risks_questions" }, -]; - -interface SavedPhase { - phase: string; - title: string; - summary: string; - data: Record; - saved_at: string; -} - -function formatValue(v: unknown): string { - if (v === null || v === undefined) return "—"; - if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", "); - return String(v); -} - -function PhaseDataCard({ phase }: { phase: SavedPhase }) { - const [expanded, setExpanded] = useState(false); - const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== ""); - return ( -
- - {expanded && entries.length > 0 && ( -
- {entries.map(([k, v]) => ( -
-
- {k.replace(/_/g, " ")} -
-
- {formatValue(v)} -
-
- ))} -
- )} -
- ); -} - -interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] } -interface ArchInfra { name: string; reason: string } -interface ArchPackage { name: string; description: string } -interface ArchIntegration { name: string; required?: boolean; notes?: string } -interface Architecture { - productName?: string; - productType?: string; - summary?: string; - apps?: ArchApp[]; - packages?: ArchPackage[]; - infrastructure?: ArchInfra[]; - integrations?: ArchIntegration[]; - designSurfaces?: string[]; - riskNotes?: string[]; -} - -function ArchitectureView({ arch }: { arch: Architecture }) { - const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( -
-
{title}
- {children} -
- ); - const Card = ({ children }: { children: React.ReactNode }) => ( -
{children}
- ); - const Tag = ({ label }: { label: string }) => ( - {label} - ); - - return ( -
- {arch.summary && ( -
- {arch.summary} -
- )} - {(arch.apps ?? []).length > 0 && ( -
- {arch.apps!.map(a => ( - -
- {a.name} - {a.type} -
-
{a.description}
- {a.tech?.map(t => )} - {a.screens && a.screens.length > 0 && ( -
Screens: {a.screens.join(", ")}
- )} -
- ))} -
- )} - {(arch.packages ?? []).length > 0 && ( -
- {arch.packages!.map(p => ( - -
- {p.name} - {p.description} -
-
- ))} -
- )} - {(arch.infrastructure ?? []).length > 0 && ( -
- {arch.infrastructure!.map(i => ( - -
{i.name}
-
{i.reason}
-
- ))} -
- )} - {(arch.integrations ?? []).length > 0 && ( -
- {arch.integrations!.map(i => ( - -
- {i.name} - {i.required && required} -
- {i.notes &&
{i.notes}
} -
- ))} -
- )} - {(arch.riskNotes ?? []).length > 0 && ( -
- {arch.riskNotes!.map((r, i) => ( -
- - {r} -
- ))} -
- )} -
- ); -} - -export default function PRDPage() { - const params = useParams(); - const projectId = params.projectId as string; - const workspace = params.workspace as string; - const [prd, setPrd] = useState(null); - const [architecture, setArchitecture] = useState(null); - const [savedPhases, setSavedPhases] = useState([]); - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd"); - const [archGenerating, setArchGenerating] = useState(false); - const [archError, setArchError] = useState(null); - - useEffect(() => { - Promise.all([ - fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})), - fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })), - ]).then(([projectData, phaseData]) => { - setPrd(projectData?.project?.prd ?? null); - setArchitecture(projectData?.project?.architecture ?? null); - setSavedPhases(phaseData?.phases ?? []); - setLoading(false); - }); - }, [projectId]); - - const router = useRouter(); - - const handleGenerateArchitecture = async () => { - setArchGenerating(true); - setArchError(null); - try { - const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Generation failed"); - setArchitecture(data.architecture); - setActiveTab("architecture"); - } catch (e) { - setArchError(e instanceof Error ? e.message : "Something went wrong"); - } finally { - setArchGenerating(false); - } - }; - - const phaseMap = new Map(savedPhases.map(p => [p.phase, p])); - const savedPhaseIds = new Set(savedPhases.map(p => p.phase)); - - const sections = PRD_SECTIONS.map(s => ({ - ...s, - savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null, - isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false, - })); - - const doneCount = sections.filter(s => s.isDone).length; - const totalPct = Math.round((doneCount / sections.length) * 100); - - if (loading) { - return ( -
- Loading… -
- ); - } - - const tabs = [ - { id: "prd" as const, label: "PRD", available: true }, - { id: "architecture" as const, label: "Architecture", available: !!architecture }, - ]; - - return ( -
- - {/* Tab bar — only when at least one doc exists */} - {(prd || architecture) && ( -
- {tabs.map(t => { - const isActive = activeTab === t.id; - return ( - - ); - })} -
- )} - - {/* Next step banner — PRD done but no architecture yet */} - {prd && !architecture && activeTab === "prd" && ( -
-
-
- Next: Generate technical architecture -
-
- The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds. -
- {archError && ( -
⚠ {archError}
- )} -
- -
- )} - - {/* Architecture tab */} - {activeTab === "architecture" && architecture && ( - - )} - - {/* PRD tab — finalized */} - {activeTab === "prd" && prd && ( -
-
-

- Product Requirements -

- - PRD complete - -
-
- {prd} -
-
- )} - - {/* PRD tab — section progress (no finalized PRD yet) */} - {activeTab === "prd" && !prd && ( - /* ── Section progress view ── */ -
- {/* Progress bar */} -
-
- {totalPct}% -
-
-
-
-
-
- - {doneCount}/{sections.length} sections - -
- - {/* Sections */} - {sections.map((s, i) => ( -
-
- {/* Status icon */} -
- {s.isDone ? "✓" : "○"} -
- - - {s.label} - - - {s.isDone && s.savedPhase && ( - - saved - - )} - {!s.isDone && !s.phaseId && ( - - generated - - )} -
- - {/* Expandable phase data */} - {s.isDone && s.savedPhase && ( - - )} - - {/* Pending hint */} - {!s.isDone && ( -
- {s.phaseId - ? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn` - : "Will be generated when PRD is finalized"} -
- )} -
- ))} - - {doneCount === 0 && ( -

- Continue chatting with Vibn — saved phases will appear here automatically. -

- )} -
- )} -
- ); +/** Legacy URL — project work now lives under Tasks (PRD is the first task). */ +export default async function PrdRedirectPage({ + params, +}: { + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/tasks`); } diff --git a/app/[workspace]/project/[projectId]/(workspace)/run/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/run/page.tsx new file mode 100644 index 0000000..1c77339 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/run/page.tsx @@ -0,0 +1,5 @@ +import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel"; + +export default function RunPage() { + return ; +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx new file mode 100644 index 0000000..deb559b --- /dev/null +++ b/app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { useEffect, useState, type CSSProperties } from "react"; +import { useParams } from "next/navigation"; + +// Maps each PRD section to the discovery phase that populates it +const PRD_SECTIONS = [ + { id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" }, + { id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" }, + { id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" }, + { id: "users_personas", label: "Users & Personas", phaseId: "users_personas" }, + { id: "user_flows", label: "User Flows", phaseId: "users_personas" }, + { id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" }, + { id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" }, + { id: "business_model", label: "Business Model", phaseId: "business_model" }, + { id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" }, + { id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" }, + { id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" }, + { id: "open_questions", label: "Open Questions", phaseId: "risks_questions" }, +]; + +interface SavedPhase { + phase: string; + title: string; + summary: string; + data: Record; + saved_at: string; +} + +function formatValue(v: unknown): string { + if (v === null || v === undefined) return "—"; + if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", "); + return String(v); +} + +function PhaseDataCard({ phase }: { phase: SavedPhase }) { + const [expanded, setExpanded] = useState(false); + const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== ""); + return ( +
+ + {expanded && entries.length > 0 && ( +
+ {entries.map(([k, v]) => ( +
+
+ {k.replace(/_/g, " ")} +
+
+ {formatValue(v)} +
+
+ ))} +
+ )} +
+ ); +} + +interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] } +interface ArchInfra { name: string; reason: string } +interface ArchPackage { name: string; description: string } +interface ArchIntegration { name: string; required?: boolean; notes?: string } +interface Architecture { + productName?: string; + productType?: string; + summary?: string; + apps?: ArchApp[]; + packages?: ArchPackage[]; + infrastructure?: ArchInfra[]; + integrations?: ArchIntegration[]; + designSurfaces?: string[]; + riskNotes?: string[]; +} + +function ArchitectureView({ arch }: { arch: Architecture }) { + const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+
{title}
+ {children} +
+ ); + const Card = ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ); + const Tag = ({ label }: { label: string }) => ( + {label} + ); + + return ( +
+ {arch.summary && ( +
+ {arch.summary} +
+ )} + {(arch.apps ?? []).length > 0 && ( +
+ {arch.apps!.map(a => ( + +
+ {a.name} + {a.type} +
+
{a.description}
+ {a.tech?.map(t => )} + {a.screens && a.screens.length > 0 && ( +
Screens: {a.screens.join(", ")}
+ )} +
+ ))} +
+ )} + {(arch.packages ?? []).length > 0 && ( +
+ {arch.packages!.map(p => ( + +
+ {p.name} + {p.description} +
+
+ ))} +
+ )} + {(arch.infrastructure ?? []).length > 0 && ( +
+ {arch.infrastructure!.map(i => ( + +
{i.name}
+
{i.reason}
+
+ ))} +
+ )} + {(arch.integrations ?? []).length > 0 && ( +
+ {arch.integrations!.map(i => ( + +
+ {i.name} + {i.required && required} +
+ {i.notes &&
{i.notes}
} +
+ ))} +
+ )} + {(arch.riskNotes ?? []).length > 0 && ( +
+ {arch.riskNotes!.map((r, i) => ( +
+ + {r} +
+ ))} +
+ )} +
+ ); +} + +export default function TasksPage() { + const params = useParams(); + const projectId = params.projectId as string; + const workspace = params.workspace as string; + const [prd, setPrd] = useState(null); + const [architecture, setArchitecture] = useState(null); + const [savedPhases, setSavedPhases] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd"); + const [archGenerating, setArchGenerating] = useState(false); + const [archError, setArchError] = useState(null); + + useEffect(() => { + Promise.all([ + fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})), + fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })), + ]).then(([projectData, phaseData]) => { + setPrd(projectData?.project?.prd ?? null); + setArchitecture(projectData?.project?.architecture ?? null); + setSavedPhases(phaseData?.phases ?? []); + setLoading(false); + }); + }, [projectId]); + + const handleGenerateArchitecture = async () => { + setArchGenerating(true); + setArchError(null); + try { + const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Generation failed"); + setArchitecture(data.architecture); + setActiveTab("architecture"); + } catch (e) { + setArchError(e instanceof Error ? e.message : "Something went wrong"); + } finally { + setArchGenerating(false); + } + }; + + const phaseMap = new Map(savedPhases.map(p => [p.phase, p])); + const savedPhaseIds = new Set(savedPhases.map(p => p.phase)); + + const sections = PRD_SECTIONS.map(s => ({ + ...s, + savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null, + isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false, + })); + + const doneCount = sections.filter(s => s.isDone).length; + const totalPct = Math.round((doneCount / sections.length) * 100); + + if (loading) { + return ( +
+ Loading tasks… +
+ ); + } + + const reqStatus = prd + ? "Complete" + : doneCount > 0 + ? `In progress · ${doneCount}/${sections.length} sections` + : "Not started"; + const archStatus = architecture + ? "Complete" + : prd + ? "Ready to generate" + : "Blocked — finish requirements first"; + + const taskCardBase: CSSProperties = { + flex: "1 1 240px", + maxWidth: 320, + textAlign: "left" as const, + padding: "14px 16px", + borderRadius: 10, + cursor: "pointer", + fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", + transition: "border-color 0.12s, box-shadow 0.12s", + }; + + return ( +
+ +
+

+ Tasks +

+

+ Work is tracked as tasks—similar in spirit to agent task boards like{" "} + + Claude Agent Teams UI + + . Your product requirements (PRD) is the first task; technical architecture is the next once requirements are captured. +

+
+ + {/* Task selector — PRD is a task; architecture is a follow-on task */} +
+ + +
+ + {/* Next step banner — PRD done but no architecture yet */} + {prd && !architecture && activeTab === "prd" && ( +
+
+
+ Next: Generate technical architecture +
+
+ The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds. +
+ {archError && ( +
⚠ {archError}
+ )} +
+ +
+ )} + + {/* Architecture tab */} + {activeTab === "architecture" && architecture && ( + + )} + + {/* PRD tab — finalized */} + {activeTab === "prd" && prd && ( +
+
+

+ Product Requirements +

+ + PRD complete + +
+
+ {prd} +
+
+ )} + + {/* PRD tab — section progress (no finalized PRD yet) */} + {activeTab === "prd" && !prd && ( + /* ── Section progress view ── */ +
+ {/* Progress bar */} +
+
+ {totalPct}% +
+
+
+
+
+
+ + {doneCount}/{sections.length} sections + +
+ + {/* Sections */} + {sections.map((s, i) => ( +
+
+ {/* Status icon */} +
+ {s.isDone ? "✓" : "○"} +
+ + + {s.label} + + + {s.isDone && s.savedPhase && ( + + saved + + )} + {!s.isDone && !s.phaseId && ( + + generated + + )} +
+ + {/* Expandable phase data */} + {s.isDone && s.savedPhase && ( + + )} + + {/* Pending hint */} + {!s.isDone && ( +
+ {s.phaseId + ? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn` + : "Will be generated when PRD is finalized"} +
+ )} +
+ ))} + + {doneCount === 0 && ( +

+ Continue chatting with Vibn — saved phases will appear here automatically. +

+ )} +
+ )} +
+ ); +} diff --git a/app/[workspace]/project/[projectId]/layout.tsx b/app/[workspace]/project/[projectId]/layout.tsx index 2e27008..fc8d862 100644 --- a/app/[workspace]/project/[projectId]/layout.tsx +++ b/app/[workspace]/project/[projectId]/layout.tsx @@ -1,72 +1,12 @@ -import { ProjectShell } from "@/components/layout/project-shell"; -import { query } from "@/lib/db-postgres"; +/** + * Passthrough layout for the project route. + * + * Two sibling route groups provide their own scaffolds: + * - (home)/ — VIBNSidebar scaffold for the project home page. + * - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc. + */ +import { ReactNode } from "react"; -interface ProjectData { - name: string; - description?: string; - status?: string; - progress?: number; - discoveryPhase?: number; - capturedData?: Record; - createdAt?: string; - updatedAt?: string; - featureCount?: number; - creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; -} - -async function getProjectData(projectId: string): Promise { - try { - const rows = await query<{ data: any; created_at?: string; updated_at?: string }>( - `SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`, - [projectId] - ); - if (rows.length > 0) { - const { data, created_at, updated_at } = rows[0]; - return { - name: data?.productName || data?.name || "Project", - description: data?.productVision || data?.description, - status: data?.status, - progress: data?.progress ?? 0, - discoveryPhase: data?.discoveryPhase ?? 0, - capturedData: data?.capturedData ?? {}, - createdAt: created_at, - updatedAt: updated_at, - featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0), - creationMode: data?.creationMode ?? "fresh", - }; - } - } catch (error) { - console.error("Error fetching project:", error); - } - return { name: "Project" }; -} - -export default async function ProjectLayout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ workspace: string; projectId: string }>; -}) { - const { workspace, projectId } = await params; - const project = await getProjectData(projectId); - - return ( - - {children} - - ); +export default function ProjectRootLayout({ children }: { children: ReactNode }) { + return <>{children}; } diff --git a/app/[workspace]/projects/page.tsx b/app/[workspace]/projects/page.tsx index 5aa3b8f..355fd87 100644 --- a/app/[workspace]/projects/page.tsx +++ b/app/[workspace]/projects/page.tsx @@ -184,7 +184,7 @@ export default function ProjectsPage() { style={{ position: "relative", animationDelay: `${i * 0.05}s` }} > {}); + } +} diff --git a/app/api/github/connect/route.ts b/app/api/github/connect/route.ts index bf4b598..040b0a5 100644 --- a/app/api/github/connect/route.ts +++ b/app/api/github/connect/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function POST(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -43,7 +42,7 @@ export async function POST(request: Request) { export async function GET(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -73,7 +72,7 @@ export async function GET(request: Request) { export async function DELETE(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/advisor/route.ts b/app/api/projects/[projectId]/advisor/route.ts index b48636d..4853cf6 100644 --- a/app/api/projects/[projectId]/advisor/route.ts +++ b/app/api/projects/[projectId]/advisor/route.ts @@ -10,8 +10,7 @@ * and injects it as knowledge_context into the orchestrator's system prompt. */ import { NextRequest } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com'; @@ -49,7 +48,6 @@ async function buildKnowledgeContext(projectId: string, email: string): Promise< const architecture = d.architecture as Record | null ?? null; const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? []; const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? ''; - const theiaUrl = (d.theiaWorkspaceUrl as string) ?? ''; const lines: string[] = []; @@ -65,14 +63,13 @@ Operating principles: - Be brief. No preamble, no "Great question!". - You decide the technical approach — never ask the founder to choose. - Be honest when you're uncertain or when data isn't available. -- Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`); +- Do NOT spawn agents on the protected platform repos (vibn-frontend, vibn-agent-runner, vibn-api, master-ai).`); // Project identity lines.push(`\n## Project: ${name}`); if (vision) lines.push(`Vision: ${vision}`); if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`); if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`); - if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`); // Architecture document if (architecture) { @@ -129,7 +126,7 @@ export async function POST( ) { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return new Response('Unauthorized', { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent-chat/route.ts b/app/api/projects/[projectId]/agent-chat/route.ts index 846a1ea..3f4693c 100644 --- a/app/api/projects/[projectId]/agent-chat/route.ts +++ b/app/api/projects/[projectId]/agent-chat/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -87,7 +86,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -114,7 +113,6 @@ export async function POST( p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null, p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null, p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null, - p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null, ].filter(Boolean); projectContext = lines.join("\n"); } @@ -190,7 +188,7 @@ export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts index 23cfe66..1792665 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts @@ -8,8 +8,7 @@ * Body: { commitMessage: string } */ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -29,7 +28,7 @@ export async function POST( ) { try { const { projectId, sessionId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/route.ts index 1125d03..007e843 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/route.ts @@ -6,8 +6,7 @@ * Batch append from vibn-agent-runner (x-agent-runner-secret). */ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query, getPool } from "@/lib/db-postgres"; export interface AgentSessionEventRow { @@ -23,7 +22,7 @@ export async function GET( ) { try { const { projectId, sessionId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream/route.ts index 1c2171b..73d5883 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream/route.ts @@ -2,8 +2,7 @@ * GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0 * Server-Sent Events: tail agent_session_events while the session is active. */ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query, queryOne } from "@/lib/db-postgres"; export const dynamic = "force-dynamic"; @@ -17,7 +16,7 @@ export async function GET( req: Request, { params }: { params: Promise<{ projectId: string; sessionId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return new Response("Unauthorized", { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/retry/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/retry/route.ts index 539ba19..9841098 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/retry/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/retry/route.ts @@ -9,8 +9,7 @@ * understands what was already tried */ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -21,7 +20,7 @@ export async function POST( ) { try { const { projectId, sessionId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts index 3a1e644..85e09a7 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts @@ -7,8 +7,7 @@ * (handled in /stop/route.ts) */ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; export async function GET( @@ -17,7 +16,7 @@ export async function GET( ) { try { const { projectId, sessionId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts index 5a868c9..75755db 100644 --- a/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -11,7 +10,7 @@ export async function POST( ) { try { const { projectId, sessionId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/agent/sessions/route.ts b/app/api/projects/[projectId]/agent/sessions/route.ts index 06adfd0..951f431 100644 --- a/app/api/projects/[projectId]/agent/sessions/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/route.ts @@ -9,8 +9,7 @@ * List all sessions for a project, newest first. */ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -33,7 +32,7 @@ export async function POST( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -131,7 +130,7 @@ export async function GET( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/analysis-status/route.ts b/app/api/projects/[projectId]/analysis-status/route.ts index 72d20ce..188cc4d 100644 --- a/app/api/projects/[projectId]/analysis-status/route.ts +++ b/app/api/projects/[projectId]/analysis-status/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function GET( @@ -9,7 +8,7 @@ export async function GET( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/analyze-chats/route.ts b/app/api/projects/[projectId]/analyze-chats/route.ts index 3585d04..8aad66d 100644 --- a/app/api/projects/[projectId]/analyze-chats/route.ts +++ b/app/api/projects/[projectId]/analyze-chats/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export const maxDuration = 60; @@ -37,7 +36,7 @@ export async function POST( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/analyze-repo/route.ts b/app/api/projects/[projectId]/analyze-repo/route.ts index 2e500d5..d7d7414 100644 --- a/app/api/projects/[projectId]/analyze-repo/route.ts +++ b/app/api/projects/[projectId]/analyze-repo/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { execSync } from 'child_process'; import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs'; @@ -79,7 +78,7 @@ export async function POST( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/analyze/route.ts b/app/api/projects/[projectId]/analyze/route.ts index 85f8bc4..92fff72 100644 --- a/app/api/projects/[projectId]/analyze/route.ts +++ b/app/api/projects/[projectId]/analyze/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333'; @@ -10,7 +9,7 @@ export async function GET( _req: Request, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -68,7 +67,7 @@ export async function POST( _req: Request, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/apps/route.ts b/app/api/projects/[projectId]/apps/route.ts index d0d2701..a1f1de7 100644 --- a/app/api/projects/[projectId]/apps/route.ts +++ b/app/api/projects/[projectId]/apps/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; @@ -25,7 +24,7 @@ export async function GET( ) { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -125,7 +124,7 @@ export async function PATCH( ) { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/architecture/route.ts b/app/api/projects/[projectId]/architecture/route.ts index 0db21cd..f4f003d 100644 --- a/app/api/projects/[projectId]/architecture/route.ts +++ b/app/api/projects/[projectId]/architecture/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -13,7 +12,7 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -43,7 +42,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -184,7 +183,7 @@ export async function PATCH( _req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/atlas-chat/route.ts b/app/api/projects/[projectId]/atlas-chat/route.ts index 527b429..da5b5bf 100644 --- a/app/api/projects/[projectId]/atlas-chat/route.ts +++ b/app/api/projects/[projectId]/atlas-chat/route.ts @@ -1,18 +1,47 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; +import { + augmentAtlasMessage, + parseContextRefs, +} from "@/lib/chat-context-refs"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; +const ALLOWED_SCOPES = new Set(["overview", "build"]); + +function normalizeScope(raw: string | null | undefined): "overview" | "build" { + const s = (raw ?? "overview").trim(); + return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview"; +} + +function runnerSessionId(projectId: string, scope: "overview" | "build"): string { + return scope === "overview" ? `atlas_${projectId}` : `atlas_${projectId}__build`; +} + // --------------------------------------------------------------------------- -// DB helpers — atlas_conversations table +// DB — atlas_chat_threads (project_id + scope); legacy atlas_conversations → overview // --------------------------------------------------------------------------- -let tableReady = false; +let threadsTableReady = false; +let legacyTableChecked = false; -async function ensureTable() { - if (tableReady) return; +async function ensureThreadsTable() { + if (threadsTableReady) return; + await query(` + CREATE TABLE IF NOT EXISTS atlas_chat_threads ( + project_id TEXT NOT NULL, + scope TEXT NOT NULL, + messages JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, scope) + ) + `); + threadsTableReady = true; +} + +async function ensureLegacyConversationsTable() { + if (legacyTableChecked) return; await query(` CREATE TABLE IF NOT EXISTS atlas_conversations ( project_id TEXT PRIMARY KEY, @@ -20,31 +49,47 @@ async function ensureTable() { updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); - tableReady = true; + legacyTableChecked = true; } -async function loadAtlasHistory(projectId: string): Promise { +async function loadAtlasHistory(projectId: string, scope: "overview" | "build"): Promise { try { - await ensureTable(); + await ensureThreadsTable(); const rows = await query<{ messages: any[] }>( - `SELECT messages FROM atlas_conversations WHERE project_id = $1`, - [projectId] + `SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`, + [projectId, scope] ); - return rows[0]?.messages ?? []; + if (rows.length > 0) { + const fromThreads = rows[0]?.messages; + return Array.isArray(fromThreads) ? fromThreads : []; + } + if (scope === "overview") { + await ensureLegacyConversationsTable(); + const leg = await query<{ messages: any[] }>( + `SELECT messages FROM atlas_conversations WHERE project_id = $1`, + [projectId] + ); + const legacyMsgs = leg[0]?.messages ?? []; + if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) { + await saveAtlasHistory(projectId, scope, legacyMsgs); + return legacyMsgs; + } + } + return []; } catch { return []; } } -async function saveAtlasHistory(projectId: string, messages: any[]): Promise { +async function saveAtlasHistory(projectId: string, scope: "overview" | "build", messages: any[]): Promise { try { - await ensureTable(); + await ensureThreadsTable(); await query( - `INSERT INTO atlas_conversations (project_id, messages, updated_at) - VALUES ($1, $2::jsonb, NOW()) - ON CONFLICT (project_id) DO UPDATE - SET messages = $2::jsonb, updated_at = NOW()`, - [projectId, JSON.stringify(messages)] + `INSERT INTO atlas_chat_threads (project_id, scope, messages, updated_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (project_id, scope) DO UPDATE + SET messages = $3::jsonb, updated_at = NOW()`, + [projectId, scope, JSON.stringify(messages)] ); } catch (e) { console.error("[atlas-chat] Failed to save history:", e); @@ -66,21 +111,36 @@ async function savePrd(projectId: string, prdContent: string): Promise { } } +/** Replace the latest user message content so DB/UI never show the internal ref prefix. */ +function scrubLastUserMessageContent(history: unknown[], cleanText: string): unknown[] { + if (!Array.isArray(history) || history.length === 0) return history; + const h = history.map(m => (m && typeof m === "object" ? { ...(m as object) } : m)); + for (let i = h.length - 1; i >= 0; i--) { + const m = h[i] as { role?: string; content?: string }; + if (m?.role === "user" && typeof m.content === "string") { + h[i] = { ...m, content: cleanText }; + break; + } + } + return h; +} + // --------------------------------------------------------------------------- // GET — load stored conversation messages for display // --------------------------------------------------------------------------- export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { projectId } = await params; - const history = await loadAtlasHistory(projectId); + const scope = normalizeScope(req.nextUrl.searchParams.get("scope")); + const history = await loadAtlasHistory(projectId, scope); // Filter to only user/assistant messages (no system prompts) for display const messages = history @@ -98,43 +158,50 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { projectId } = await params; - const { message } = await req.json(); + const body = await req.json(); + const message = body?.message as string | undefined; + const contextRefs = parseContextRefs(body?.contextRefs); if (!message?.trim()) { return NextResponse.json({ error: "message is required" }, { status: 400 }); } - const sessionId = `atlas_${projectId}`; + const scope = normalizeScope(body?.scope as string | undefined); + const sessionId = runnerSessionId(projectId, scope); + const cleanUserText = message.trim(); // Load conversation history from DB to persist across agent runner restarts. // Strip tool_call / tool_response messages — replaying them across sessions // causes Gemini to reject the request with a turn-ordering error. - const rawHistory = await loadAtlasHistory(projectId); + const rawHistory = await loadAtlasHistory(projectId, scope); const history = rawHistory.filter((m: any) => (m.role === "user" || m.role === "assistant") && m.content ); // __init__ is a special internal trigger used only when there is no existing history. // If history already exists, ignore the init request (conversation already started). - const isInit = message.trim() === "__atlas_init__"; + const isInit = cleanUserText === "__atlas_init__"; if (isInit && history.length > 0) { return NextResponse.json({ reply: null, alreadyStarted: true }); } + const runnerMessage = isInit + ? scope === "build" + ? "Begin as Vibn in build mode. The user is working in their monorepo. Ask what they want to ship or fix next, and offer concrete implementation guidance. Do not acknowledge this as an internal trigger." + : "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger." + : augmentAtlasMessage(cleanUserText, contextRefs); + try { const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - // For init, send the greeting prompt but don't store it as a user message - message: isInit - ? "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger." - : message, + message: runnerMessage, session_id: sessionId, history, is_init: isInit, @@ -153,11 +220,16 @@ export async function POST( const data = await res.json(); - // Persist updated history - await saveAtlasHistory(projectId, data.history ?? []); + let historyOut = data.history ?? []; + // Store the user's line without the internal reference block (UI shows clean text). + if (!isInit && cleanUserText !== "__atlas_init__") { + historyOut = scrubLastUserMessageContent(historyOut, cleanUserText); + } - // If Atlas finalized the PRD, save it to the project - if (data.prdContent) { + await saveAtlasHistory(projectId, scope, historyOut); + + // If Atlas finalized the PRD, save it to the project (discovery / overview) + if (data.prdContent && scope === "overview") { await savePrd(projectId, data.prdContent); } @@ -181,24 +253,35 @@ export async function POST( // --------------------------------------------------------------------------- export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { projectId } = await params; - const sessionId = `atlas_${projectId}`; + const scope = normalizeScope(req.nextUrl.searchParams.get("scope")); + const sessionId = runnerSessionId(projectId, scope); try { - await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" }); + await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); } catch { /* runner may be down */ } try { - await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]); + await ensureThreadsTable(); + await query( + `DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`, + [projectId, scope] + ); } catch { /* table may not exist yet */ } + if (scope === "overview") { + try { + await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]); + } catch { /* legacy */ } + } + return NextResponse.json({ cleared: true }); } diff --git a/app/api/projects/[projectId]/design-surfaces/route.ts b/app/api/projects/[projectId]/design-surfaces/route.ts index 785e83e..6f75efd 100644 --- a/app/api/projects/[projectId]/design-surfaces/route.ts +++ b/app/api/projects/[projectId]/design-surfaces/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; /** @@ -12,7 +11,7 @@ export async function GET( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const rows = await query<{ data: Record }>( @@ -49,7 +48,7 @@ export async function PATCH( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); // Step 1: read current data — explicit ::text casts on every param diff --git a/app/api/projects/[projectId]/file/route.ts b/app/api/projects/[projectId]/file/route.ts index 03a6eef..4e6306c 100644 --- a/app/api/projects/[projectId]/file/route.ts +++ b/app/api/projects/[projectId]/file/route.ts @@ -6,8 +6,7 @@ * Response for file: { type: "file", content: string, encoding: "utf8" | "base64" } */ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; @@ -39,7 +38,7 @@ export async function GET( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/generate-migration-plan/route.ts b/app/api/projects/[projectId]/generate-migration-plan/route.ts index 19b4189..44d963d 100644 --- a/app/api/projects/[projectId]/generate-migration-plan/route.ts +++ b/app/api/projects/[projectId]/generate-migration-plan/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export const maxDuration = 120; @@ -28,7 +27,7 @@ export async function POST( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/knowledge/import-ai-chat/route.ts b/app/api/projects/[projectId]/knowledge/import-ai-chat/route.ts index 9b113d5..7987edd 100644 --- a/app/api/projects/[projectId]/knowledge/import-ai-chat/route.ts +++ b/app/api/projects/[projectId]/knowledge/import-ai-chat/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { createKnowledgeItem } from '@/lib/server/knowledge'; import type { KnowledgeSourceMeta } from '@/lib/types/knowledge'; @@ -34,7 +33,7 @@ export async function POST( return NextResponse.json({ error: 'transcript is required' }, { status: 400 }); } - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/knowledge/items/route.ts b/app/api/projects/[projectId]/knowledge/items/route.ts index 64acbee..0c30116 100644 --- a/app/api/projects/[projectId]/knowledge/items/route.ts +++ b/app/api/projects/[projectId]/knowledge/items/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function GET( @@ -10,7 +9,7 @@ export async function GET( try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/knowledge/route.ts b/app/api/projects/[projectId]/knowledge/route.ts index dcc4788..4585665 100644 --- a/app/api/projects/[projectId]/knowledge/route.ts +++ b/app/api/projects/[projectId]/knowledge/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; async function assertOwnership(projectId: string, email: string): Promise { @@ -18,7 +17,7 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { projectId } = await params; @@ -41,7 +40,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { projectId } = await params; @@ -83,7 +82,7 @@ export async function DELETE( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { projectId } = await params; diff --git a/app/api/projects/[projectId]/preview-url/route.ts b/app/api/projects/[projectId]/preview-url/route.ts index accd86d..94e9028 100644 --- a/app/api/projects/[projectId]/preview-url/route.ts +++ b/app/api/projects/[projectId]/preview-url/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { listApplications, CoolifyApplication } from '@/lib/coolify'; @@ -20,7 +19,7 @@ export async function GET( ) { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 3b08da5..4c6f2f1 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function GET( @@ -10,7 +9,7 @@ export async function GET( try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -45,7 +44,7 @@ export async function PATCH( const { projectId } = await params; const body = await request.json(); - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/save-phase/route.ts b/app/api/projects/[projectId]/save-phase/route.ts index 016c0d1..374dc69 100644 --- a/app/api/projects/[projectId]/save-phase/route.ts +++ b/app/api/projects/[projectId]/save-phase/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth/authOptions"; +import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; // --------------------------------------------------------------------------- @@ -11,7 +10,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -85,7 +84,7 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/vision/route.ts b/app/api/projects/[projectId]/vision/route.ts index 2e4b361..3c500d5 100644 --- a/app/api/projects/[projectId]/vision/route.ts +++ b/app/api/projects/[projectId]/vision/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function POST( @@ -9,7 +8,7 @@ export async function POST( ) { try { const { projectId } = await params; - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/[projectId]/workspace/route.ts b/app/api/projects/[projectId]/workspace/route.ts deleted file mode 100644 index bf1b006..0000000 --- a/app/api/projects/[projectId]/workspace/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; -import { query } from '@/lib/db-postgres'; -import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; - -export async function POST( - _request: Request, - { params }: { params: Promise<{ projectId: string }> }, -) { - try { - const { projectId } = await params; - - const session = await getServerSession(authOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Verify ownership - const rows = await query<{ id: string; data: any }>(` - SELECT p.id, p.data - FROM fs_projects p - JOIN fs_users u ON u.id = p.user_id - WHERE p.id = $1 AND u.data->>'email' = $2 - LIMIT 1 - `, [projectId, session.user.email]); - - if (rows.length === 0) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); - } - - const project = rows[0].data; - - if (project.theiaWorkspaceUrl) { - return NextResponse.json({ - success: true, - workspaceUrl: project.theiaWorkspaceUrl, - message: 'Workspace already provisioned', - }); - } - - const slug = project.slug; - if (!slug) { - return NextResponse.json({ error: 'Project has no slug — cannot provision workspace' }, { status: 400 }); - } - - // Provision Cloud Run workspace - const workspace = await provisionTheiaWorkspace(slug, projectId, project.giteaRepo ?? null); - - // Save URL back to project record - await query(` - UPDATE fs_projects - SET data = data || jsonb_build_object( - 'theiaWorkspaceUrl', $1::text, - 'theiaAppUuid', $2::text - ) - WHERE id = $3 - `, [workspace.serviceUrl, workspace.serviceName, projectId]); - - return NextResponse.json({ - success: true, - workspaceUrl: workspace.serviceUrl, - }); - } catch (error) { - console.error('[POST /api/projects/:id/workspace] Error:', error); - return NextResponse.json( - { error: 'Failed to provision workspace', details: error instanceof Error ? error.message : String(error) }, - { status: 500 }, - ); - } -} diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 574dc84..c1fdfc1 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { pushTurborepoScaffold } from '@/lib/scaffold'; import { createMonorepoAppService } from '@/lib/coolify'; -import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; @@ -208,24 +207,7 @@ export async function POST(request: Request) { } // ────────────────────────────────────────────── - // 3. Provision dedicated Theia workspace - // ────────────────────────────────────────────── - let theiaWorkspaceUrl: string | null = null; - let theiaAppUuid: string | null = null; - let theiaError: string | null = null; - - try { - const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo); - theiaWorkspaceUrl = workspace.serviceUrl; - theiaAppUuid = workspace.serviceName; - console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`); - } catch (err) { - theiaError = err instanceof Error ? err.message : String(err); - console.error('[API] Theia workspace provisioning failed (non-fatal):', theiaError); - } - - // ────────────────────────────────────────────── - // 4. Save project record + // 3. Save project record // ────────────────────────────────────────────── const projectData = { id: projectId, @@ -262,10 +244,6 @@ export async function POST(request: Request) { giteaSshUrl, giteaWebhookId, giteaError, - // Theia workspace - theiaWorkspaceUrl, - theiaAppUuid, - theiaError, // Context snapshot (kept fresh by webhooks) contextSnapshot: null, // Coolify project — one per VIBN project, scopes all app services + DBs @@ -344,8 +322,6 @@ export async function POST(request: Request) { ? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl } : null, giteaError: giteaError ?? undefined, - theiaWorkspaceUrl, - theiaError: theiaError ?? undefined, isImport: !!githubRepoUrl, analysisJobId: analysisJobId ?? undefined, }); diff --git a/app/api/projects/delete/route.ts b/app/api/projects/delete/route.ts index be70b6f..4012870 100644 --- a/app/api/projects/delete/route.ts +++ b/app/api/projects/delete/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function POST(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/deploy/route.ts b/app/api/projects/deploy/route.ts index 4ea0001..bbe2d27 100644 --- a/app/api/projects/deploy/route.ts +++ b/app/api/projects/deploy/route.ts @@ -8,14 +8,13 @@ */ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { deployApplication } from '@/lib/coolify'; export async function POST(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/projects/prewarm/route.ts b/app/api/projects/prewarm/route.ts deleted file mode 100644 index 84b62a2..0000000 --- a/app/api/projects/prewarm/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; -import { prewarmWorkspace } from '@/lib/cloud-run-workspace'; - -/** - * POST /api/projects/prewarm - * Body: { urls: string[] } - * - * Fires warm-up requests to Cloud Run workspace URLs so containers - * are running by the time the user clicks "Open IDE". Server-side - * to avoid CORS issues with run.app domains. - */ -export async function POST(req: NextRequest) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { urls } = await req.json() as { urls: string[] }; - if (!Array.isArray(urls) || urls.length === 0) { - return NextResponse.json({ warmed: 0 }); - } - - // Fire all prewarm pings in parallel — intentionally not awaited - Promise.allSettled(urls.map(url => prewarmWorkspace(url))).catch(() => {}); - - return NextResponse.json({ warmed: urls.length }); -} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index ac3f37f..ffcf749 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function GET() { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 9278ae9..eddd0ca 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; export async function GET(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json([], { status: 200 }); } diff --git a/app/api/theia-auth/route.ts b/app/api/theia-auth/route.ts deleted file mode 100644 index 94bb115..0000000 --- a/app/api/theia-auth/route.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * GET /api/theia-auth - * - * Traefik ForwardAuth endpoint for Theia IDE domains. - * - * Handles two cases: - * 1. theia.vibnai.com — shared IDE: any authenticated user may access - * 2. {slug}.ide.vibnai.com — per-project IDE: only the project owner may access - * - * Traefik calls this URL for every request to those Theia domains, forwarding - * the user's Cookie header via authRequestHeaders. We validate the NextAuth - * database session directly in Postgres (avoids Prisma / authOptions build-time - * issues under --network host). - * - * Returns: - * 200 — valid session (and owner check passed), Traefik lets the request through - * 302 — no/expired session, redirect browser to Vibn login - * 403 — authenticated but not the project owner - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { query } from '@/lib/db-postgres'; - -export const dynamic = 'force-dynamic'; - -const APP_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com'; -const THEIA_URL = 'https://theia.vibnai.com'; -const IDE_SUFFIX = '.ide.vibnai.com'; - -const SESSION_COOKIE_NAMES = [ - '__Secure-next-auth.session-token', - 'next-auth.session-token', -]; - -export async function GET(request: NextRequest) { - // ── 1. Extract session token ────────────────────────────────────────────── - let sessionToken: string | null = null; - for (const name of SESSION_COOKIE_NAMES) { - const val = request.cookies.get(name)?.value; - if (val) { sessionToken = val; break; } - } - - if (!sessionToken) return redirectToLogin(request); - - // ── 2. Validate session in Postgres ────────────────────────────────────── - let userEmail: string | null = null; - let userName: string | null = null; - let userId: string | null = null; - - try { - const rows = await query<{ email: string; name: string; user_id: string }>( - `SELECT u.email, u.name, s.user_id - FROM sessions s - JOIN users u ON u.id = s.user_id - WHERE s.session_token = $1 - AND s.expires > NOW() - LIMIT 1`, - [sessionToken], - ); - if (rows.length > 0) { - userEmail = rows[0].email; - userName = rows[0].name; - userId = rows[0].user_id; - } - } catch (err) { - console.error('[theia-auth] DB error:', err); - return redirectToLogin(request); - } - - if (!userEmail || !userId) return redirectToLogin(request); - - // ── 3. Per-project ownership check for *.ide.vibnai.com ────────────────── - const forwardedHost = request.headers.get('x-forwarded-host') ?? ''; - - if (forwardedHost.endsWith(IDE_SUFFIX)) { - const slug = forwardedHost.slice(0, -IDE_SUFFIX.length); - - try { - const rows = await query<{ user_id: string }>( - `SELECT user_id FROM fs_projects WHERE slug = $1 LIMIT 1`, - [slug], - ); - - if (rows.length === 0) { - // Unknown project slug — deny - return new NextResponse('Workspace not found', { status: 403 }); - } - - const ownerUserId = rows[0].user_id; - if (ownerUserId !== userId) { - // Authenticated but not the owner - return new NextResponse('Access denied — this workspace belongs to another user', { status: 403 }); - } - } catch (err) { - console.error('[theia-auth] project ownership check error:', err); - return redirectToLogin(request); - } - } - - // ── 4. Allow — pass user identity headers to Theia ─────────────────────── - return new NextResponse(null, { - status: 200, - headers: { - 'X-Auth-Email': userEmail, - 'X-Auth-Name': userName ?? '', - }, - }); -} - -function redirectToLogin(request: NextRequest): NextResponse { - // Use THEIA_URL as the callbackUrl so the user lands back on Theia after login. - // (X-Forwarded-Host points to vibnai.com via Traefik, not the original Theia domain.) - const loginUrl = `${APP_URL}/auth?callbackUrl=${encodeURIComponent(THEIA_URL)}`; - return NextResponse.redirect(loginUrl, { status: 302 }); -} diff --git a/app/api/user/api-key/route.ts b/app/api/user/api-key/route.ts index 3296818..ce53321 100644 --- a/app/api/user/api-key/route.ts +++ b/app/api/user/api-key/route.ts @@ -1,12 +1,11 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth/authOptions'; +import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { v4 as uuidv4 } from 'uuid'; export async function GET(request: Request) { try { - const session = await getServerSession(authOptions); + const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 }); } diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx index 64f6233..6f0908c 100644 --- a/app/auth/layout.tsx +++ b/app/auth/layout.tsx @@ -1,4 +1,20 @@ +import type { Metadata } from "next"; +import { Plus_Jakarta_Sans } from "next/font/google"; import { Toaster } from "sonner"; +import { JustineAuthShell } from "@/marketing/components/justine/JustineAuthShell"; +import "../styles/justine/02-signup.css"; + +const justineJakarta = Plus_Jakarta_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600", "700", "800"], + variable: "--font-justine-jakarta", + display: "swap", +}); + +export const metadata: Metadata = { + title: "Sign in · vibn", + description: "Sign in to your vibn workspace with Google.", +}; export default function AuthLayout({ children, @@ -6,10 +22,12 @@ export default function AuthLayout({ children: React.ReactNode; }) { return ( - <> - {children} +
+ {children} - +
); } - diff --git a/app/auth/page.tsx b/app/auth/page.tsx index 1d9c671..b0c9e84 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -16,23 +16,17 @@ function AuthPageInner() { useEffect(() => { if (status === "authenticated" && session?.user?.email) { - const callbackUrl = searchParams.get("callbackUrl"); - // Only follow external callbackUrls we control (Theia subdomain) - if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) { - window.location.href = callbackUrl; - } else { - const workspace = deriveWorkspace(session.user.email); - router.push(`/${workspace}/projects`); - } + const workspace = deriveWorkspace(session.user.email); + router.push(`/${workspace}/projects`); } }, [status, session, router, searchParams]); if (status === "loading") { return ( -
-
-
-

Loading authentication...

+
+
+
+

Loading authentication…

); diff --git a/app/components/NextAuthComponent.tsx b/app/components/NextAuthComponent.tsx index e0af278..ca8d746 100644 --- a/app/components/NextAuthComponent.tsx +++ b/app/components/NextAuthComponent.tsx @@ -1,84 +1,190 @@ "use client"; import { signIn } from "next-auth/react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; -export default function NextAuthComponent() { +function authErrorMessage(code: string | null): string | null { + if (!code) return null; + if (code === "Callback") { + return ( + "Google could not complete sign-in. Most often: DATABASE_URL in vibn-frontend/.env.local must reach Postgres from " + + "this machine (Coolify internal hostnames only work inside Docker). Use a public host/port, tunnel, or proxy; " + + "then run npx prisma db push. Also confirm NEXTAUTH_URL matches the browser (http://localhost:3000) and " + + "Google redirect URI http://localhost:3000/api/auth/callback/google. Dev check: GET /api/debug/prisma — see terminal for [next-auth] logs." + ); + } + if (code === "Configuration") { + return "Auth is misconfigured (check GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET)."; + } + if (code === "AccessDenied") { + return "Access was denied. You may need to be added as a test user if the OAuth app is in testing mode."; + } + return `Sign-in error: ${code}`; +} + +const showDevLocalSignIn = + process.env.NODE_ENV === "development" && + Boolean(process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL?.trim()); + +function NextAuthForm() { const [isLoading, setIsLoading] = useState(false); + const [devSecret, setDevSecret] = useState(""); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") ?? "/auth"; + const errorCode = searchParams.get("error"); + const errorHint = authErrorMessage(errorCode); const handleGoogleSignIn = async () => { setIsLoading(true); try { - // Sign in with Google using NextAuth - await signIn("google", { - callbackUrl: "/auth", - }); + await signIn("google", { callbackUrl }); } catch (error) { console.error("Google sign-in error:", error); setIsLoading(false); } }; + const handleDevLocalSignIn = async () => { + setIsLoading(true); + try { + await signIn("dev-local", { + callbackUrl, + password: devSecret, + redirect: true, + }); + } catch (error) { + console.error("Dev local sign-in error:", error); + setIsLoading(false); + } + }; + return ( -
-
- {/* Logo */} -
- Vib'n -
+
+
+

Welcome back

+

Sign in with Google to open your workspace.

- {/* Auth Card */} - - - - Welcome to Vib'n - - - Sign in to continue - - - -
+ )} + + + + {showDevLocalSignIn && ( +
+

+ Local only: sign in without Google as{" "} + {process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL} +

+
{ + e.preventDefault(); + void handleDevLocalSignIn(); + }} > - - - - - - - {isLoading ? "Signing in..." : "Continue with Google"} - - - + setDevSecret(e.target.value)} + className="justine-auth-dev-input" + style={{ + width: "100%", + marginBottom: 10, + padding: "10px 12px", + borderRadius: 8, + border: "1px solid rgba(0,0,0,0.12)", + fontSize: 14, + }} + /> + +
+
+ )} - {/* Footer */} -

- By continuing, you agree to our Terms of Service and Privacy Policy. +

+ By continuing, you agree to our{" "} + Terms and Privacy Policy.

); } + +function NextAuthFallback() { + return ( +
+
+
+

Loading…

+
+
+ ); +} + +export default function NextAuthComponent() { + return ( + }> + + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index eb88285..42258ef 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -58,13 +58,20 @@ export default function RootLayout({ {children} -