From ccbaf77fddc134e39ab30846a2f6e4dee0af449a Mon Sep 17 00:00:00 2001 From: suharshit singh Date: Tue, 15 Sep 2026 22:33:24 +0530 Subject: [PATCH] feat(ai-sessions): run the AI Architect chat on stored sessions A reload no longer loses the chat or an in-flight run. Generation is still one-shot; this changes persistence and resume only. - POST /api/projects/[projectId]/ai-sessions/[sessionId]/turns stores the prompt and a PENDING reply carrying the run id (replaces POST /api/ai/design, which is removed) - Replies are settled from runs.retrieve whenever a session is read, so a turn is saved even if the tab closed mid-run; concurrent reads settle once - useAiSession replaces useDesignAgent: reopens the last chat, resumes pending runs via Realtime with a 4s polling fallback, stores new chats lazily on the first message - Sidebar session bar with saved-chat history (delete, expiry) and New chat - Limits moved to lib/ai/session-limits.ts for client use; formatRelativeTime extracted to lib/relative-time.ts Co-Authored-By: Claude Opus 5 --- app/api/ai/design/route.ts | 94 ---- .../ai-sessions/[sessionId]/route.ts | 11 +- .../ai-sessions/[sessionId]/turns/route.ts | 71 +++ components/editor/ai-session-history.tsx | 97 ++++ components/editor/ai-sidebar.tsx | 226 ++++++--- components/editor/project-sidebar.tsx | 14 +- context/progress-tracker.md | 51 +- hooks/use-ai-session.ts | 479 ++++++++++++++++++ hooks/use-design-agent.ts | 252 --------- lib/ai/session-limits.ts | 25 + lib/ai/session-store.ts | 30 +- lib/ai/session-turns.ts | 227 +++++++++ lib/relative-time.ts | 13 + 13 files changed, 1147 insertions(+), 443 deletions(-) delete mode 100644 app/api/ai/design/route.ts create mode 100644 app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts create mode 100644 components/editor/ai-session-history.tsx create mode 100644 hooks/use-ai-session.ts delete mode 100644 hooks/use-design-agent.ts create mode 100644 lib/ai/session-limits.ts create mode 100644 lib/ai/session-turns.ts create mode 100644 lib/relative-time.ts diff --git a/app/api/ai/design/route.ts b/app/api/ai/design/route.ts deleted file mode 100644 index 0558aba..0000000 --- a/app/api/ai/design/route.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { auth } from "@clerk/nextjs/server"; -import { tasks } from "@trigger.dev/sdk/v3"; - -import { prisma } from "@/lib/prisma"; -import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; -import type { designAgentTask } from "@/src/trigger/design-agent"; - -interface DesignRequestBody { - prompt: string; - roomId: string; - projectId: string; -} - -function parseRequestBody(value: unknown): DesignRequestBody | null { - if (value === null || typeof value !== "object") { - return null; - } - - const candidate = value as Record; - - if ( - typeof candidate.prompt !== "string" || - typeof candidate.roomId !== "string" || - typeof candidate.projectId !== "string" - ) { - return null; - } - - const prompt = candidate.prompt.trim(); - const roomId = candidate.roomId.trim(); - const projectId = candidate.projectId.trim(); - - if (!prompt || !roomId || !projectId) { - return null; - } - - return { prompt, roomId, projectId }; -} - -export async function POST(request: Request) { - const { userId } = await auth(); - if (!userId) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid request body" }, { status: 400 }); - } - - const parsedBody = parseRequestBody(body); - if (!parsedBody) { - return Response.json( - { error: "prompt, roomId, and projectId are required" }, - { status: 400 }, - ); - } - - const { primaryEmail } = await getCurrentIdentity(); - const project = await getAccessibleProject(userId, primaryEmail, parsedBody.projectId); - if (!project) { - return Response.json({ error: "Forbidden" }, { status: 403 }); - } - - if (parsedBody.roomId !== parsedBody.projectId) { - return Response.json({ error: "roomId must match projectId" }, { status: 400 }); - } - - let handle: Awaited>>; - try { - handle = await tasks.trigger("design-agent", { - prompt: parsedBody.prompt, - roomId: parsedBody.roomId, - }); - } catch (error) { - // wrong-environment TRIGGER_SECRET_KEY, a branch env that does not exist, or - // no deployed version of the task. Surface it as 502 so the client does not - // mistake it for an expired session, and log the cause for the server side. - console.error("[api/ai/design] failed to trigger design-agent", error); - return Response.json({ error: "Design service unavailable" }, { status: 502 }); - } - - await prisma.taskRun.create({ - data: { - runId: handle.id, - projectId: parsedBody.projectId, - userId, - }, - }); - - return Response.json({ runId: handle.id }, { status: 202 }); -} diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts index 5482e64..ce606b5 100644 --- a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts @@ -1,6 +1,7 @@ import { auth } from "@clerk/nextjs/server"; -import { deleteSession, getSession } from "@/lib/ai/session-store"; +import { deleteSession } from "@/lib/ai/session-store"; +import { getSettledSession } from "@/lib/ai/session-turns"; import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; type SessionRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; @@ -34,7 +35,11 @@ async function resolveScope(projectId: string) { return { scope: { projectId: project.id, userId } } as const; } -/** Returns one of the current user's sessions with its full transcript. */ +/** + * Returns one of the current user's sessions with its full transcript. Turns + * whose runs have finished are settled first, so the reply is there even if the + * tab that sent the message was closed. + */ export async function GET(_request: Request, context: SessionRouteContext) { const { projectId, sessionId } = await context.params; const result = await resolveScope(projectId); @@ -42,7 +47,7 @@ export async function GET(_request: Request, context: SessionRouteContext) { return result.error; } - const session = await getSession(result.scope, sessionId); + const session = await getSettledSession(result.scope, sessionId); if (!session) { return notFoundResponse(); } diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts new file mode 100644 index 0000000..218a8b2 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts @@ -0,0 +1,71 @@ +import { auth } from "@clerk/nextjs/server"; + +import { MAX_MESSAGE_LENGTH } from "@/lib/ai/session-limits"; +import { startTurn } from "@/lib/ai/session-turns"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +type TurnRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; + +/** Only `{ type: "message", text }` for now; answers/generate/skip arrive with the turn engine. */ +function parseMessageText(value: unknown): string | null { + if (typeof value !== "object" || value === null) { + return null; + } + + const candidate = value as Record; + if (candidate.type !== "message" || typeof candidate.text !== "string") { + return null; + } + + const text = candidate.text.trim(); + if (text.length === 0 || text.length > MAX_MESSAGE_LENGTH) { + return null; + } + + return text; +} + +/** + * Sends a message in one of the current user's sessions and starts the design + * run that answers it. Responds 202 with the updated session (the reply is a + * PENDING message carrying the run id), or 502 with the session when the run + * could not be started. + */ +export async function POST(request: Request, context: TurnRouteContext) { + const { userId } = await auth(); + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + const text = parseMessageText(body); + if (!text) { + return Response.json( + { error: `Message text is required and must be at most ${MAX_MESSAGE_LENGTH} characters` }, + { status: 400 }, + ); + } + + const { projectId, sessionId } = await context.params; + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + const result = await startTurn({ projectId: project.id, userId }, sessionId, text); + if (result.ok) { + return Response.json({ session: result.session }, { status: 202 }); + } + + return Response.json( + { error: result.error, ...(result.session ? { session: result.session } : {}) }, + { status: result.status }, + ); +} diff --git a/components/editor/ai-session-history.tsx b/components/editor/ai-session-history.tsx new file mode 100644 index 0000000..615cbbe --- /dev/null +++ b/components/editor/ai-session-history.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; + +import { AI_SESSION_TTL_DAYS, MAX_SESSIONS_PER_USER_PROJECT } from "@/lib/ai/session-limits"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { cn } from "@/lib/utils"; +import type { AiSessionSummary } from "@/types/ai-session"; + +interface AiSessionHistoryProps { + sessions: AiSessionSummary[]; + activeSessionId: string | null; + /** False while a message is being sent. */ + canSwitch: boolean; + onSelect: (sessionId: string) => void; + onDelete: (sessionId: string) => void; +} + +const focusClass = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink/60"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function describeExpiry(expiresAt: string, now: number): string { + const remaining = new Date(expiresAt).getTime() - now; + if (remaining < DAY_MS) { + return "expires today"; + } + return `expires in ${Math.floor(remaining / DAY_MS)}d`; +} + +/** The AI Architect's saved chats, shown in place of the transcript. */ +export function AiSessionHistory({ sessions, activeSessionId, canSwitch, onSelect, onDelete }: AiSessionHistoryProps) { + // Captured when the list opens; relative times don't need to tick while it is visible. + const [now] = useState(() => Date.now()); + + return ( +
+
+ Recent chats + + {sessions.length}/{MAX_SESSIONS_PER_USER_PROJECT} + +
+ + {sessions.length === 0 ? ( +

+ No saved chats yet. Send a message to start one. +

+ ) : ( +
    + {sessions.map((entry) => { + const isActive = entry.id === activeSessionId; + + return ( +
  • + + +
  • + ); + })} +
+ )} + +

+ Chats are kept for {AI_SESSION_TTL_DAYS} days after last use +

+
+ ); +} diff --git a/components/editor/ai-sidebar.tsx b/components/editor/ai-sidebar.tsx index 9a5db6c..97963a5 100644 --- a/components/editor/ai-sidebar.tsx +++ b/components/editor/ai-sidebar.tsx @@ -1,10 +1,11 @@ "use client"; -import { FormEvent, KeyboardEvent, useState } from "react"; +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import { ArrowRight, Download, FileText, Loader2, Sparkle, X } from "lucide-react"; +import { ArrowRight, ChevronDown, Download, FileText, History, Loader2, Plus, Sparkle, X } from "lucide-react"; -import { useDesignAgent } from "@/hooks/use-design-agent"; +import { AiSessionHistory } from "@/components/editor/ai-session-history"; +import { useAiSession } from "@/hooks/use-ai-session"; import { cn } from "@/lib/utils"; interface AiSidebarProps { @@ -42,9 +43,34 @@ function SparkleMark({ className }: { className?: string }) { export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { const [inputValue, setInputValue] = useState(""); - const { messages, statusText, isRunning, sendPrompt } = useDesignAgent(projectId); + const [showHistory, setShowHistory] = useState(false); + const { + sessions, + activeSessionId, + activeSessionTitle, + messages, + statusText, + isRunning, + canSwitchSession, + isLoadingSession, + sessionLoadFailed, + sendPrompt, + startNewChat, + selectSession, + removeSession, + retryLoadSession, + } = useAiSession(projectId); - const showEmptyState = messages.length === 0; + const transcriptEndRef = useRef(null); + + const showEmptyState = messages.length === 0 && !isLoadingSession && !sessionLoadFailed; + + // Keep the latest message in view, including when a saved chat opens. + useEffect(() => { + if (!showHistory) { + transcriptEndRef.current?.scrollIntoView({ block: "end" }); + } + }, [messages.length, showHistory, statusText]); const sendMessage = (text: string) => { if (!text.trim() || isRunning) { @@ -53,6 +79,7 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { sendPrompt(text); setInputValue(""); + setShowHistory(false); }; const handleSubmit = (event: FormEvent) => { @@ -67,6 +94,16 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { } }; + const handleNewChat = () => { + startNewChat(); + setShowHistory(false); + }; + + const handleSelectSession = (sessionId: string) => { + selectSession(sessionId); + setShowHistory(false); + }; + return (