feat(chat): render architecture generation button from NEXT_STEP marker

- Detect [[NEXT_STEP:{...}]] marker in Atlas messages alongside existing
  [[PHASE_COMPLETE:{...}]] - extracted via extractMarkers()
- When action=generate_architecture, render an inline action card in
  the chat: button calls POST /architecture, shows spinner while
  generating, then success state with direct link to Build tab
- Add spin keyframe; thread workspace param through MessageRow

Made-with: Cursor
This commit is contained in:
2026-03-03 21:18:34 -08:00
parent a3aa5e4208
commit d30af447da

View File

@@ -2,6 +2,8 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useParams } from "next/navigation";
import Link from "next/link";
interface ChatMessage { interface ChatMessage {
role: "user" | "assistant"; role: "user" | "assistant";
@@ -14,9 +16,11 @@ interface AtlasChatProps {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase marker — Atlas appends [[PHASE_COMPLETE:{...}]] when a phase wraps up // Markers — Atlas appends these at end of messages to signal UI actions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const PHASE_MARKER_RE = /\[\[PHASE_COMPLETE:(.*?)\]\]/s; const PHASE_MARKER_RE = /\[\[PHASE_COMPLETE:(.*?)\]\]/s;
const NEXT_STEP_RE = /\[\[NEXT_STEP:(.*?)\]\]/s;
interface PhasePayload { interface PhasePayload {
phase: string; phase: string;
@@ -25,15 +29,33 @@ interface PhasePayload {
data: Record<string, unknown>; data: Record<string, unknown>;
} }
function extractPhase(text: string): { clean: string; phase: PhasePayload | null } { interface NextStepPayload {
const match = text.match(PHASE_MARKER_RE); action: string;
if (!match) return { clean: text, phase: null }; label: string;
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 };
} }
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 };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -54,12 +76,27 @@ function renderContent(text: string | null | undefined) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Message row // Message row
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function MessageRow({ msg, userInitial, projectId }: { msg: ChatMessage; userInitial: string; projectId: string }) { function MessageRow({
msg, userInitial, projectId, workspace,
}: {
msg: ChatMessage;
userInitial: string;
projectId: string;
workspace: string;
}) {
const isAtlas = msg.role === "assistant"; const isAtlas = msg.role === "assistant";
const { clean, phase } = isAtlas ? extractPhase(msg.content ?? "") : { clean: msg.content ?? "", phase: null }; const { clean, phase, nextStep } = isAtlas
? extractMarkers(msg.content ?? "")
: { clean: msg.content ?? "", phase: null, nextStep: null };
// Phase save state
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Architecture generation state
const [archState, setArchState] = useState<"idle" | "loading" | "done" | "error">("idle");
const [archError, setArchError] = useState<string | null>(null);
const handleSavePhase = async () => { const handleSavePhase = async () => {
if (!phase || saved || saving) return; if (!phase || saved || saving) return;
setSaving(true); setSaving(true);
@@ -70,13 +107,30 @@ function MessageRow({ msg, userInitial, projectId }: { msg: ChatMessage; userIni
body: JSON.stringify(phase), body: JSON.stringify(phase),
}); });
setSaved(true); setSaved(true);
} catch { } catch { /* swallow — user can retry */ } finally {
// swallow — user can retry
} finally {
setSaving(false); 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 ( return (
<div style={{ display: "flex", gap: 12, marginBottom: 22, animation: "enter 0.3s ease both" }}> <div style={{ display: "flex", gap: 12, marginBottom: 22, animation: "enter 0.3s ease both" }}>
{/* Avatar */} {/* Avatar */}
@@ -107,7 +161,8 @@ function MessageRow({ msg, userInitial, projectId }: { msg: ChatMessage; userIni
}}> }}>
{renderContent(clean)} {renderContent(clean)}
</div> </div>
{/* Phase save button — only shown when Atlas signals phase completion */}
{/* Phase save button */}
{phase && ( {phase && (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<button <button
@@ -138,6 +193,87 @@ function MessageRow({ msg, userInitial, projectId }: { msg: ChatMessage; userIni
)} )}
</div> </div>
)} )}
{/* Next step — architecture generation */}
{nextStep?.action === "generate_architecture" && (
<div style={{
marginTop: 16,
padding: "16px 18px",
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 10,
borderLeft: "3px solid #1a1a1a",
}}>
{archState === "done" ? (
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#2e7d32", marginBottom: 6 }}>
Architecture generated
</div>
<p style={{ fontSize: "0.76rem", color: "#6b6560", margin: "0 0 12px", lineHeight: 1.5 }}>
Review the recommended apps, services, and infrastructure then confirm when you&apos;re ready.
</p>
<Link
href={`/${workspace}/project/${projectId}/build`}
style={{
display: "inline-block", padding: "8px 16px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.76rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", textDecoration: "none",
}}
>
Review architecture
</Link>
</div>
) : archState === "error" ? (
<div>
<div style={{ fontSize: "0.78rem", color: "#c62828", marginBottom: 8 }}>
{archError}
</div>
<button
onClick={() => { setArchState("idle"); setArchError(null); }}
style={{
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
background: "none", fontSize: "0.74rem", color: "#6b6560",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
}}
>
Try again
</button>
</div>
) : (
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 5 }}>
Next: Technical architecture
</div>
<p style={{ fontSize: "0.76rem", color: "#6b6560", margin: "0 0 14px", lineHeight: 1.55 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes about 30 seconds.
</p>
<button
onClick={handleGenerateArchitecture}
disabled={archState === "loading"}
style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "9px 18px", borderRadius: 8, border: "none",
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
color: "#fff", fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif",
cursor: archState === "loading" ? "default" : "pointer",
transition: "background 0.15s",
}}
>
{archState === "loading" && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #ffffff40", borderTopColor: "#fff",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archState === "loading" ? "Analysing PRD…" : nextStep.label}
</button>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -171,6 +307,8 @@ function TypingIndicator() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function AtlasChat({ projectId }: AtlasChatProps) { export function AtlasChat({ projectId }: AtlasChatProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const params = useParams();
const workspace = (params?.workspace as string) ?? "";
const userInitial = const userInitial =
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.name?.[0]?.toUpperCase() ??
session?.user?.email?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ??
@@ -292,6 +430,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
<style>{` <style>{`
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} } @keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
@keyframes enter { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } @keyframes enter { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
@keyframes spin { to { transform:rotate(360deg); } }
`}</style> `}</style>
{/* Empty state */} {/* Empty state */}
@@ -336,7 +475,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
Reset Reset
</button> </button>
{visibleMessages.map((msg, i) => ( {visibleMessages.map((msg, i) => (
<MessageRow key={i} msg={msg} userInitial={userInitial} projectId={projectId} /> <MessageRow key={i} msg={msg} userInitial={userInitial} projectId={projectId} workspace={workspace} />
))} ))}
{isStreaming && <TypingIndicator />} {isStreaming && <TypingIndicator />}
<div ref={endRef} /> <div ref={endRef} />