diff --git a/app/[workspace]/project/[projectId]/prd/page.tsx b/app/[workspace]/project/[projectId]/prd/page.tsx index 0a5c840..e581c47 100644 --- a/app/[workspace]/project/[projectId]/prd/page.tsx +++ b/app/[workspace]/project/[projectId]/prd/page.tsx @@ -6,13 +6,16 @@ import { useParams } from "next/navigation"; const PRD_SECTIONS = [ { id: "executive_summary", label: "Executive Summary" }, { id: "problem_statement", label: "Problem Statement" }, + { id: "vision_metrics", label: "Vision & Success Metrics" }, { id: "users_personas", label: "Users & Personas" }, { id: "user_flows", label: "User Flows" }, { id: "feature_requirements", label: "Feature Requirements" }, { id: "screen_specs", label: "Screen Specs" }, { id: "business_model", label: "Business Model" }, + { id: "integrations", label: "Integrations & Dependencies" }, { id: "non_functional", label: "Non-Functional Reqs" }, - { id: "risks", label: "Risks" }, + { id: "risks", label: "Risks & Mitigations" }, + { id: "open_questions", label: "Open Questions" }, ]; interface PRDSection { diff --git a/app/api/projects/[projectId]/atlas-chat/route.ts b/app/api/projects/[projectId]/atlas-chat/route.ts index 8216315..46cae5e 100644 --- a/app/api/projects/[projectId]/atlas-chat/route.ts +++ b/app/api/projects/[projectId]/atlas-chat/route.ts @@ -66,6 +66,30 @@ async function savePrd(projectId: string, prdContent: string): Promise { } } +// --------------------------------------------------------------------------- +// GET — load stored conversation messages for display +// --------------------------------------------------------------------------- + +export async function GET( + _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 history = await loadAtlasHistory(projectId); + + // Filter to only user/assistant messages (no system prompts) for display + const messages = history + .filter((m: any) => m.role === "user" || m.role === "assistant") + .map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content as string })); + + return NextResponse.json({ messages }); +} + // --------------------------------------------------------------------------- // POST — send message to Atlas // --------------------------------------------------------------------------- @@ -90,14 +114,25 @@ export async function POST( // Load conversation history from DB to persist across agent runner restarts const history = await loadAtlasHistory(projectId); + // __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__"; + if (isInit && history.length > 0) { + return NextResponse.json({ reply: null, alreadyStarted: true }); + } + try { const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - message, + // For init, send the greeting prompt but don't store it as a user message + message: isInit + ? "Begin the conversation. Introduce yourself as Atlas and ask what the user is building. Do not acknowledge this as an internal trigger." + : message, session_id: sessionId, history, + is_init: isInit, }), signal: AbortSignal.timeout(120_000), }); diff --git a/components/AtlasChat.tsx b/components/AtlasChat.tsx index a37b0ad..8154fc9 100644 --- a/components/AtlasChat.tsx +++ b/components/AtlasChat.tsx @@ -1,34 +1,129 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { useSession } from "next-auth/react"; -import { - AssistantRuntimeProvider, - useLocalRuntime, - type ChatModelAdapter, -} from "@assistant-ui/react"; -import { Thread } from "@/components/assistant-ui/thread"; + +interface ChatMessage { + role: "user" | "assistant"; + content: string; +} interface AtlasChatProps { projectId: string; projectName?: string; } -function makeAtlasAdapter(projectId: string): ChatModelAdapter { - return { - async run({ messages, abortSignal }) { - const lastUser = [...messages].reverse().find((m) => m.role === "user"); - const text = - lastUser?.content - .filter((p) => p.type === "text") - .map((p) => (p as { type: "text"; text: string }).text) - .join("") ?? ""; +// --------------------------------------------------------------------------- +// Markdown-lite renderer — handles **bold**, newlines, numbered/bullet lists +// --------------------------------------------------------------------------- +function renderContent(text: string) { + return text.split("\n").map((line, i) => { + const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) => + seg.startsWith("**") && seg.endsWith("**") + ? {seg.slice(2, -2)} + : {seg} + ); + return
{parts}
; + }); +} +// --------------------------------------------------------------------------- +// Message row +// --------------------------------------------------------------------------- +function MessageRow({ msg, userInitial }: { msg: ChatMessage; userInitial: string }) { + const isAtlas = msg.role === "assistant"; + return ( +
+ {/* Avatar */} +
+ {isAtlas ? "A" : userInitial} +
+
+ {/* Label */} +
+ {isAtlas ? "Atlas" : "You"} +
+ {/* Content */} +
+ {renderContent(msg.content)} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Typing indicator +// --------------------------------------------------------------------------- +function TypingIndicator() { + return ( +
+
A
+
+ {[0, 1, 2].map(d => ( +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +export function AtlasChat({ projectId }: AtlasChatProps) { + const { data: session } = useSession(); + const userInitial = + session?.user?.name?.[0]?.toUpperCase() ?? + session?.user?.email?.[0]?.toUpperCase() ?? + "Y"; + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [historyLoaded, setHistoryLoaded] = useState(false); + const initTriggered = useRef(false); + const endRef = useRef(null); + + // Scroll to bottom whenever messages change + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isStreaming]); + + // Send a message to Atlas — optionally hidden from UI (for init trigger) + const sendToAtlas = useCallback(async (text: string, hideUserMsg = false) => { + if (!hideUserMsg) { + setMessages(prev => [...prev, { role: "user", content: text }]); + } + setIsStreaming(true); + + try { const res = await fetch(`/api/projects/${projectId}/atlas-chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), - signal: abortSignal, }); if (!res.ok) { @@ -37,60 +132,172 @@ function makeAtlasAdapter(projectId: string): ChatModelAdapter { } const data = await res.json(); - return { content: [{ type: "text", text: data.reply || "…" }] }; - }, - }; -} -function AtlasChatInner({ - projectId, - projectName, - userInitial, - runtime, -}: AtlasChatProps & { - userInitial: string; - runtime: ReturnType; -}) { - const greeted = useRef(false); + // alreadyStarted means the init was called but history already exists — ignore + if (data.alreadyStarted) return; + if (data.reply) { + setMessages(prev => [...prev, { role: "assistant", content: data.reply }]); + } + } catch (e) { + const msg = e instanceof Error ? e.message : "Something went wrong."; + setMessages(prev => [...prev, { role: "assistant", content: msg }]); + } finally { + setIsStreaming(false); + } + }, [projectId]); + + // On mount: load stored history; if empty, trigger Atlas greeting useEffect(() => { - if (greeted.current) return; - greeted.current = true; - const opener = `Hey — I'm starting a new project called "${projectName || "my project"}". I'd love your help defining what we're building.`; - const t = setTimeout(() => { - runtime.thread.composer.setText(opener); - runtime.thread.composer.send(); - }, 300); - return () => clearTimeout(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (historyLoaded) return; + + fetch(`/api/projects/${projectId}/atlas-chat`) + .then(r => r.json()) + .then((data: { messages: ChatMessage[] }) => { + const stored = data.messages ?? []; + setMessages(stored); + setHistoryLoaded(true); + + // Only trigger greeting if there's genuinely no history yet + if (stored.length === 0 && !initTriggered.current) { + initTriggered.current = true; + sendToAtlas("__atlas_init__", true); + } + }) + .catch(() => { + setHistoryLoaded(true); + // If we can't load, still try to greet on first open + if (!initTriggered.current) { + initTriggered.current = true; + sendToAtlas("__atlas_init__", true); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const handleSend = () => { + const text = input.trim(); + if (!text || isStreaming) return; + setInput(""); + sendToAtlas(text, false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const isEmpty = messages.length === 0 && !isStreaming; return ( - // No card — fills the layout space directly -
- +
+ + + {/* Empty state */} + {isEmpty && ( +
+
A
+ +
+

Atlas

+

+ Your product strategist. Let's define what you're building. +

+
+
+ )} + + {/* Messages */} + {!isEmpty && ( +
+ {messages.map((msg, i) => ( + + ))} + {isStreaming && } +
+
+ )} + + {/* Loading history state */} + {isEmpty && isStreaming && ( +
+ +
+ )} + + {/* Input bar */} +
+
+