From 5bfbe8654125f4331f4c4180652d5ae77019ad1d Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 2 Mar 2026 20:24:08 -0800 Subject: [PATCH] feat: inline Save Phase button in Atlas chat when phase is complete Made-with: Cursor --- .../projects/[projectId]/save-phase/route.ts | 107 ++++++++++++++++++ components/AtlasChat.tsx | 81 ++++++++++++- 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 app/api/projects/[projectId]/save-phase/route.ts diff --git a/app/api/projects/[projectId]/save-phase/route.ts b/app/api/projects/[projectId]/save-phase/route.ts new file mode 100644 index 0000000..016c0d1 --- /dev/null +++ b/app/api/projects/[projectId]/save-phase/route.ts @@ -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 }; + 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: [] }); + } +} diff --git a/components/AtlasChat.tsx b/components/AtlasChat.tsx index a48ae58..a3bec6c 100644 --- a/components/AtlasChat.tsx +++ b/components/AtlasChat.tsx @@ -13,6 +13,29 @@ interface AtlasChatProps { 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 // --------------------------------------------------------------------------- @@ -31,8 +54,29 @@ function renderContent(text: string | null | undefined) { // --------------------------------------------------------------------------- // 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 { 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 */} @@ -61,8 +105,39 @@ function MessageRow({ msg, userInitial }: { msg: ChatMessage; userInitial: strin fontFamily: "Outfit, sans-serif", whiteSpace: isAtlas ? "normal" : "pre-wrap", }}> - {renderContent(msg.content)} + {renderContent(clean)}
+ {/* Phase save button — only shown when Atlas signals phase completion */} + {phase && ( +
+ + {!saved && ( +
+ {phase.summary} +
+ )} +
+ )} ); @@ -261,7 +336,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) { Reset {visibleMessages.map((msg, i) => ( - + ))} {isStreaming && }