"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { useSession } from "next-auth/react"; import { useParams } from "next/navigation"; import Link from "next/link"; interface ChatMessage { role: "user" | "assistant"; content: string; } interface AtlasChatProps { projectId: string; projectName?: string; } // --------------------------------------------------------------------------- // Markers — Atlas appends these at end of messages to signal UI actions // --------------------------------------------------------------------------- const PHASE_MARKER_RE = /\[\[PHASE_COMPLETE:(.*?)\]\]/s; const NEXT_STEP_RE = /\[\[NEXT_STEP:(.*?)\]\]/s; interface PhasePayload { phase: string; title: string; summary: string; data: Record; } interface NextStepPayload { action: string; label: string; } function extractMarkers(text: string): { clean: string; phase: PhasePayload | null; nextStep: NextStepPayload | null; } { let clean = text; let phase: PhasePayload | null = null; let nextStep: NextStepPayload | null = null; const phaseMatch = clean.match(PHASE_MARKER_RE); if (phaseMatch) { try { phase = JSON.parse(phaseMatch[1]); } catch { /* ignore */ } clean = clean.replace(PHASE_MARKER_RE, "").trimEnd(); } const nextMatch = clean.match(NEXT_STEP_RE); if (nextMatch) { try { nextStep = JSON.parse(nextMatch[1]); } catch { /* ignore */ } clean = clean.replace(NEXT_STEP_RE, "").trimEnd(); } return { clean, phase, nextStep }; } // --------------------------------------------------------------------------- // Markdown-lite renderer — handles **bold**, newlines, numbered/bullet lists // --------------------------------------------------------------------------- function renderContent(text: string | null | undefined) { if (!text) return null; 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, projectId, workspace, }: { msg: ChatMessage; userInitial: string; projectId: string; workspace: string; }) { const isAtlas = msg.role === "assistant"; const { clean, phase, nextStep } = isAtlas ? extractMarkers(msg.content ?? "") : { clean: msg.content ?? "", phase: null, nextStep: null }; // Phase save state const [saved, setSaved] = useState(false); const [saving, setSaving] = useState(false); // Architecture generation state const [archState, setArchState] = useState<"idle" | "loading" | "done" | "error">("idle"); const [archError, setArchError] = useState(null); const handleSavePhase = async () => { if (!phase || saved || saving) return; setSaving(true); try { await fetch(`/api/projects/${projectId}/save-phase`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(phase), }); setSaved(true); } catch { /* swallow — user can retry */ } finally { setSaving(false); } }; const handleGenerateArchitecture = async () => { if (archState !== "idle") return; setArchState("loading"); setArchError(null); try { const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const d = await res.json(); if (!res.ok) throw new Error(d.error || "Generation failed"); setArchState("done"); } catch (e) { setArchError(e instanceof Error ? e.message : "Something went wrong"); setArchState("error"); } }; return (
{/* Avatar */}
{isAtlas ? "A" : userInitial}
{/* Label */}
{isAtlas ? "Atlas" : "You"}
{/* Content */}
{renderContent(clean)}
{/* Phase save button */} {phase && (
{!saved && (
{phase.summary}
)}
)} {/* Next step — architecture generation */} {nextStep?.action === "generate_architecture" && (
{archState === "done" ? (
✓ Architecture generated

Review the recommended apps, services, and infrastructure — then confirm when you're ready.

Review architecture →
) : archState === "error" ? (
⚠ {archError}
) : (
Next: Technical architecture

The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes about 30 seconds.

)}
)}
); } // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- function TypingIndicator() { return (
A
{[0, 1, 2].map(d => (
))}
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function AtlasChat({ projectId }: AtlasChatProps) { const { data: session } = useSession(); const params = useParams(); const workspace = (params?.workspace as string) ?? ""; 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 }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || "Atlas is unavailable. Please try again."); } const data = await res.json(); // 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 exactly once useEffect(() => { let cancelled = false; // guard against unmount during fetch fetch(`/api/projects/${projectId}/atlas-chat`) .then(r => r.json()) .then((data: { messages: ChatMessage[] }) => { if (cancelled) return; const stored = data.messages ?? []; setMessages(stored); setHistoryLoaded(true); // Only greet if there is genuinely no history and we haven't triggered yet if (stored.length === 0 && !initTriggered.current) { initTriggered.current = true; sendToAtlas("__atlas_init__", true); } }) .catch(() => { if (cancelled) return; setHistoryLoaded(true); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); const handleReset = async () => { if (!confirm("Clear this conversation and start fresh?")) return; try { await fetch(`/api/projects/${projectId}/atlas-chat`, { method: "DELETE" }); setMessages([]); setHistoryLoaded(false); initTriggered.current = false; // Trigger fresh greeting setTimeout(() => { initTriggered.current = true; sendToAtlas("__atlas_init__", true); }, 100); } catch { // swallow } }; 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 visibleMessages = messages.filter(msg => msg.content); const isEmpty = visibleMessages.length === 0 && !isStreaming; return (
{/* Empty state */} {isEmpty && (
A

Atlas

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

)} {/* Messages */} {!isEmpty && (
{/* Reset button — top right, only visible on hover of the area */} {visibleMessages.map((msg, i) => ( ))} {isStreaming && }
)} {/* Loading history state */} {isEmpty && isStreaming && (
)} {/* Input bar */}