diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/overview/page.tsx index 550968d..ff0bf00 100644 --- a/app/[workspace]/project/[projectId]/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/overview/page.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { OrchestratorChat } from "@/components/OrchestratorChat"; +import { AtlasChat } from "@/components/AtlasChat"; import { GitBranch, GitCommit, @@ -78,6 +79,9 @@ interface Project { deploymentUrl?: string; // Theia theiaWorkspaceUrl?: string; + // Stage + stage?: 'discovery' | 'architecture' | 'building' | 'active'; + prd?: string; // Context contextSnapshot?: ContextSnapshot; stats?: { sessions: number; costs: number }; @@ -199,11 +203,18 @@ export default function ProjectOverviewPage() { return (
- {/* ── Orchestrator Chat ── */} - + {/* ── Agent Panel — Atlas for discovery, Orchestrator once PRD is done ── */} + {(!project.stage || project.stage === 'discovery') ? ( + + ) : ( + + )} {/* ── Header ── */}
diff --git a/app/api/projects/[projectId]/atlas-chat/route.ts b/app/api/projects/[projectId]/atlas-chat/route.ts new file mode 100644 index 0000000..8216315 --- /dev/null +++ b/app/api/projects/[projectId]/atlas-chat/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth/authOptions"; +import { query } from "@/lib/db-postgres"; + +const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; + +// --------------------------------------------------------------------------- +// DB helpers — atlas_conversations table +// --------------------------------------------------------------------------- + +let tableReady = false; + +async function ensureTable() { + if (tableReady) return; + await query(` + CREATE TABLE IF NOT EXISTS atlas_conversations ( + project_id TEXT PRIMARY KEY, + messages JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + tableReady = true; +} + +async function loadAtlasHistory(projectId: string): Promise { + try { + await ensureTable(); + const rows = await query<{ messages: any[] }>( + `SELECT messages FROM atlas_conversations WHERE project_id = $1`, + [projectId] + ); + return rows[0]?.messages ?? []; + } catch { + return []; + } +} + +async function saveAtlasHistory(projectId: string, messages: any[]): Promise { + try { + await ensureTable(); + 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)] + ); + } catch (e) { + console.error("[atlas-chat] Failed to save history:", e); + } +} + +async function savePrd(projectId: string, prdContent: string): Promise { + try { + await query( + `UPDATE fs_projects + SET data = data || jsonb_build_object('prd', $2, 'stage', 'architecture'), + updated_at = NOW() + WHERE id = $1`, + [projectId, prdContent] + ); + console.log(`[atlas-chat] PRD saved for project ${projectId}`); + } catch (e) { + console.error("[atlas-chat] Failed to save PRD:", e); + } +} + +// --------------------------------------------------------------------------- +// POST — send message to Atlas +// --------------------------------------------------------------------------- + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const { message } = await req.json(); + if (!message?.trim()) { + return NextResponse.json({ error: "message is required" }, { status: 400 }); + } + + const sessionId = `atlas_${projectId}`; + + // Load conversation history from DB to persist across agent runner restarts + const history = await loadAtlasHistory(projectId); + + try { + const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message, + session_id: sessionId, + history, + }), + signal: AbortSignal.timeout(120_000), + }); + + if (!res.ok) { + const text = await res.text(); + console.error("[atlas-chat] Agent runner error:", text); + return NextResponse.json( + { error: "Atlas is unavailable. Please try again." }, + { status: 502 } + ); + } + + const data = await res.json(); + + // Persist updated history + await saveAtlasHistory(projectId, data.history ?? []); + + // If Atlas finalized the PRD, save it to the project + if (data.prdContent) { + await savePrd(projectId, data.prdContent); + } + + return NextResponse.json({ + reply: data.reply, + sessionId, + prdContent: data.prdContent ?? null, + model: data.model ?? null, + }); + } catch (err) { + console.error("[atlas-chat] Error:", err); + return NextResponse.json( + { error: "Request timed out or failed. Please try again." }, + { status: 500 } + ); + } +} + +// --------------------------------------------------------------------------- +// DELETE — clear Atlas conversation for this project +// --------------------------------------------------------------------------- + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const sessionId = `atlas_${projectId}`; + + try { + await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" }); + } catch { /* runner may be down */ } + + try { + await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]); + } catch { /* table may not exist yet */ } + + return NextResponse.json({ cleared: true }); +} diff --git a/components/AtlasChat.tsx b/components/AtlasChat.tsx new file mode 100644 index 0000000..3550387 --- /dev/null +++ b/components/AtlasChat.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Send, + Loader2, + User, + RotateCcw, + FileText, + CheckCircle2, + ChevronDown, + ChevronUp, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Message { + role: "user" | "assistant"; + content: string; +} + +interface AtlasChatProps { + projectId: string; + projectName?: string; + initialMessage?: string; +} + +// --------------------------------------------------------------------------- +// Atlas avatar — distinct from orchestrator +// --------------------------------------------------------------------------- + +function AtlasAvatar() { + return ( +
+ A +
+ ); +} + +// --------------------------------------------------------------------------- +// PRD preview panel +// --------------------------------------------------------------------------- + +function PrdPanel({ content }: { content: string }) { + const [expanded, setExpanded] = useState(false); + const preview = content.split("\n").slice(0, 6).join("\n"); + + return ( +
+
+ + + PRD Generated + + + Ready for architecture phase + + +
+
+
+          {expanded ? content : preview}
+        
+ {!expanded && ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function AtlasChat({ projectId, projectName, initialMessage }: AtlasChatProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [prdContent, setPrdContent] = useState(null); + const [error, setError] = useState(null); + const [started, setStarted] = useState(false); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to latest message + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, loading]); + + // Kick off Atlas with its opening message on first load + useEffect(() => { + if (started) return; + setStarted(true); + sendMessage( + initialMessage || + `Hey — I'm starting a new project called "${projectName || "my project"}". I'd love your help defining what we're building.` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sendMessage = useCallback( + async (text: string) => { + if (!text.trim() || loading) return; + setError(null); + + const userMsg: Message = { role: "user", content: text }; + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setLoading(true); + + try { + const res = await fetch( + `/api/projects/${projectId}/atlas-chat`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + } + ); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Something went wrong. Please try again."); + setMessages((prev) => prev.slice(0, -1)); + return; + } + + const assistantMsg: Message = { + role: "assistant", + content: data.reply || "...", + }; + setMessages((prev) => [...prev, assistantMsg]); + + if (data.prdContent) { + setPrdContent(data.prdContent); + } + } catch { + setError("Couldn't reach Atlas. Check your connection and try again."); + setMessages((prev) => prev.slice(0, -1)); + } finally { + setLoading(false); + setTimeout(() => textareaRef.current?.focus(), 50); + } + }, + [loading, projectId] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(input); + } + }; + + const handleReset = async () => { + if (!confirm("Start the discovery conversation over from scratch?")) return; + await fetch(`/api/projects/${projectId}/atlas-chat`, { method: "DELETE" }); + setMessages([]); + setPrdContent(null); + setStarted(false); + setError(null); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Atlas

+

Product Requirements

+
+
+
+ {prdContent && ( +
+ + PRD ready +
+ )} + +
+
+ + {/* Messages */} + +
+ {messages.map((msg, i) => ( +
+ {msg.role === "assistant" && } + +
+

+ {msg.content} +

+
+ + {msg.role === "user" && ( +
+ +
+ )} +
+ ))} + + {/* Typing indicator */} + {loading && ( +
+ +
+
+ + + +
+
+
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* PRD panel */} + {prdContent && } + +
+
+ + + {/* Input */} +
+ {prdContent ? ( +

+ PRD complete — the platform is now architecting your solution. +

+ ) : ( +
+