"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { useSession } from "next-auth/react"; interface ChatMessage { role: "user" | "assistant"; content: string; } interface AtlasChatProps { projectId: string; projectName?: string; } // --------------------------------------------------------------------------- // Phase marker — Atlas appends [[PHASE_COMPLETE:{...}]] when a phase wraps up // --------------------------------------------------------------------------- const PHASE_MARKER_RE = /\[\[PHASE_COMPLETE:(.*?)\]\]/s; interface PhasePayload { phase: string; title: string; summary: string; data: Record; } function extractPhase(text: string): { clean: string; phase: PhasePayload | null } { const match = text.match(PHASE_MARKER_RE); if (!match) return { clean: text, phase: null }; try { const phase = JSON.parse(match[1]) as PhasePayload; return { clean: text.replace(PHASE_MARKER_RE, "").trimEnd(), phase }; } catch { return { clean: text.replace(PHASE_MARKER_RE, "").trimEnd(), phase: null }; } } // --------------------------------------------------------------------------- // 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 }: { msg: ChatMessage; userInitial: string; projectId: string }) { const isAtlas = msg.role === "assistant"; const { clean, phase } = isAtlas ? extractPhase(msg.content ?? "") : { clean: msg.content ?? "", phase: null }; const [saved, setSaved] = useState(false); const [saving, setSaving] = useState(false); 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); } }; return (
{/* Avatar */}
{isAtlas ? "A" : userInitial}
{/* Label */}
{isAtlas ? "Atlas" : "You"}
{/* Content */}
{renderContent(clean)}
{/* Phase save button — only shown when Atlas signals phase completion */} {phase && (
{!saved && (
{phase.summary}
)}
)}
); } // --------------------------------------------------------------------------- // 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 }), }); 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 */}