feat: inline Save Phase button in Atlas chat when phase is complete
Made-with: Cursor
This commit is contained in:
107
app/api/projects/[projectId]/save-phase/route.ts
Normal file
107
app/api/projects/[projectId]/save-phase/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST — save a completed discovery phase into the project data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let body: { phase: string; title: string; summary: string; data: Record<string, unknown> };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phase, title, summary, data } = body;
|
||||||
|
if (!phase || !data) {
|
||||||
|
return NextResponse.json({ error: "phase and data are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure the table has a phases column and merge the new phase in
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS atlas_phases (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
saved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (project_id, phase)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO atlas_phases (project_id, phase, title, summary, data, saved_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb, NOW())
|
||||||
|
ON CONFLICT (project_id, phase) DO UPDATE
|
||||||
|
SET title = EXCLUDED.title,
|
||||||
|
summary = EXCLUDED.summary,
|
||||||
|
data = EXCLUDED.data,
|
||||||
|
saved_at = NOW()`,
|
||||||
|
[projectId, phase, title ?? phase, summary ?? "", JSON.stringify(data)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also mirror into fs_projects.data.phases for easy access elsewhere
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects
|
||||||
|
SET data = jsonb_set(
|
||||||
|
COALESCE(data, '{}'::jsonb),
|
||||||
|
ARRAY['phases', $2],
|
||||||
|
$3::jsonb,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[projectId, phase, JSON.stringify({ title, summary, data, savedAt: new Date().toISOString() })]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[save-phase] Saved phase "${phase}" for project ${projectId}`);
|
||||||
|
return NextResponse.json({ saved: true, phase });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[save-phase] Error:", err);
|
||||||
|
return NextResponse.json({ error: "Failed to save phase" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET — load all saved phases for this project
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await query<{ phase: string; title: string; summary: string; data: unknown; saved_at: string }>(
|
||||||
|
`SELECT phase, title, summary, data, saved_at
|
||||||
|
FROM atlas_phases
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY saved_at ASC`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ phases: rows });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ phases: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,29 @@ interface AtlasChatProps {
|
|||||||
projectName?: 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Markdown-lite renderer — handles **bold**, newlines, numbered/bullet lists
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -31,8 +54,29 @@ function renderContent(text: string | null | undefined) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message row
|
// Message row
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function MessageRow({ msg, userInitial }: { msg: ChatMessage; userInitial: string }) {
|
function MessageRow({ msg, userInitial, projectId }: { msg: ChatMessage; userInitial: string; projectId: string }) {
|
||||||
const isAtlas = msg.role === "assistant";
|
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 (
|
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 */}
|
||||||
@@ -61,8 +105,39 @@ function MessageRow({ msg, userInitial }: { msg: ChatMessage; userInitial: strin
|
|||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "Outfit, sans-serif",
|
||||||
whiteSpace: isAtlas ? "normal" : "pre-wrap",
|
whiteSpace: isAtlas ? "normal" : "pre-wrap",
|
||||||
}}>
|
}}>
|
||||||
{renderContent(msg.content)}
|
{renderContent(clean)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Phase save button — only shown when Atlas signals phase completion */}
|
||||||
|
{phase && (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSavePhase}
|
||||||
|
disabled={saved || saving}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 7,
|
||||||
|
padding: "8px 16px", borderRadius: 8,
|
||||||
|
background: saved ? "#e8f5e9" : "#1a1a1a",
|
||||||
|
color: saved ? "#2e7d32" : "#fff",
|
||||||
|
border: saved ? "1px solid #a5d6a7" : "none",
|
||||||
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
cursor: saved || saving ? "default" : "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saved ? "✓ Phase saved" : saving ? "Saving…" : `Save phase — ${phase.title}`}
|
||||||
|
</button>
|
||||||
|
{!saved && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 6, fontSize: "0.72rem", color: "#a09a90",
|
||||||
|
fontFamily: "Outfit, sans-serif", lineHeight: 1.4,
|
||||||
|
}}>
|
||||||
|
{phase.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -261,7 +336,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} />
|
<MessageRow key={i} msg={msg} userInitial={userInitial} projectId={projectId} />
|
||||||
))}
|
))}
|
||||||
{isStreaming && <TypingIndicator />}
|
{isStreaming && <TypingIndicator />}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
|
|||||||
Reference in New Issue
Block a user