diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 250e734..5959b2f 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -3,16 +3,41 @@ import { PrismaClient } from '@/generated/prisma' const prisma = new PrismaClient() -// GET /api/sessions - List all sessions -export async function GET() { +// GET /api/sessions - List all sessions with pagination +export async function GET(request: Request) { try { - const sessions = await prisma.session.findMany({ - orderBy: { - createdAt: 'desc' - } - }) + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1', 10); + const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 100); // Max 100 per page + const skip = (page - 1) * limit; + + const [sessions, totalCount] = await Promise.all([ + prisma.session.findMany({ + select: { + id: true, + sandboxId: true, + shareToken: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: 'desc' + }, + skip, + take: limit, + }), + prisma.session.count(), + ]); - return NextResponse.json(sessions) + return NextResponse.json({ + sessions, + pagination: { + page, + limit, + totalCount, + totalPages: Math.ceil(totalCount / limit), + } + }); } catch (error) { console.error('[API] Failed to fetch sessions:', error) return NextResponse.json( diff --git a/src/app/api/stream/route.ts b/src/app/api/stream/route.ts index 5f1e18e..4f2272a 100644 --- a/src/app/api/stream/route.ts +++ b/src/app/api/stream/route.ts @@ -18,6 +18,11 @@ interface AgentEventData { isNew?: boolean; } +// Helper function to create SSE messages +function createSSEMessage(type: string, data: Record): string { + return `data: ${JSON.stringify({ type, data })}\n\n`; +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const sessionId = searchParams.get('sessionId'); @@ -47,88 +52,61 @@ export async function GET(request: NextRequest) { }; // Initial connection message - send(`data: ${JSON.stringify({ type: 'connected', sessionId })} - -`); + send(createSSEMessage('connected', { sessionId })); // Event handlers for different types of updates const handleAgentUpdate = (data: AgentEventData) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'status', - data: { - sessionId: data.sessionId, - status: data.status, - message: data.message, - hasSandbox: data.hasSandbox, - } - })} - -`); + send(createSSEMessage('status', { + sessionId: data.sessionId, + status: data.status, + message: data.message, + hasSandbox: data.hasSandbox, + })); } }; const handlePartialContent = (data: AgentEventData) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'partial', - data: { - sessionId: data.sessionId, - content: data.content, - fullContent: data.fullContent, - } - })} - -`); + send(createSSEMessage('partial', { + sessionId: data.sessionId, + content: data.content, + fullContent: data.fullContent, + })); } }; const handleToolUsed = (data: AgentEventData & { args?: Record; result?: string; status?: string }) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'tool', - data: { - sessionId: data.sessionId, - tool: data.tool, - args: data.args, - result: data.result, - status: data.status, - } - })} - -`); + send(createSSEMessage('tool', { + sessionId: data.sessionId, + tool: data.tool, + args: data.args, + result: data.result, + status: data.status, + })); } }; const handleSandboxStatus = (data: AgentEventData) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'sandbox', - data: { - sessionId: data.sessionId, - sandboxId: data.sandboxId, - sandboxUrl: data.sandboxUrl, - isNew: data.isNew, - } - })} - -`); + send(createSSEMessage('sandbox', { + sessionId: data.sessionId, + sandboxId: data.sandboxId, + sandboxUrl: data.sandboxUrl, + isNew: data.isNew, + })); } }; const handleComplete = (data: AgentEventData) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'complete', - data: { - sessionId: data.sessionId, - response: data.response, - sandboxUrl: data.sandboxUrl, - hasSandbox: data.hasSandbox, - } - })} - -`); + send(createSSEMessage('complete', { + sessionId: data.sessionId, + response: data.response, + sandboxUrl: data.sandboxUrl, + hasSandbox: data.hasSandbox, + })); if (data.response) { appendSessionMessages(sessionId, [{ role: 'ai', content: String(data.response), ts: Date.now() }]); } @@ -137,75 +115,50 @@ export async function GET(request: NextRequest) { const handleError = (data: AgentEventData) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'error', - data: { - sessionId: data.sessionId, - error: data.error, - } - })} - -`); + send(createSSEMessage('error', { + sessionId: data.sessionId, + error: data.error, + })); } }; const handleReasoning = (data: AgentEventData & { reasoning?: string }) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'reasoning', - data: { - sessionId: data.sessionId, - reasoning: data.reasoning, - } - })} - -`); + send(createSSEMessage('reasoning', { + sessionId: data.sessionId, + reasoning: data.reasoning, + })); } }; const handleFileUpdate = (data: AgentEventData & { filePath?: string; content?: string; action?: 'start' | 'update' | 'complete' }) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'file_update', - data: { - sessionId: data.sessionId, - filePath: data.filePath, - content: data.content, - action: data.action, - } - })} - -`); + send(createSSEMessage('file_update', { + sessionId: data.sessionId, + filePath: data.filePath, + content: data.content, + action: data.action, + })); } }; const handleCodePatch = (data: AgentEventData & { filePath?: string; content?: string; action?: 'start' | 'patch' | 'complete' }) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'code_patch', - data: { - sessionId: data.sessionId, - filePath: data.filePath, - content: data.content, - action: data.action, - } - })} - -`); + send(createSSEMessage('code_patch', { + sessionId: data.sessionId, + filePath: data.filePath, + content: data.content, + action: data.action, + })); } }; const handleFileTreeSync = (data: AgentEventData & { fileTree?: unknown }) => { if (data.sessionId === sessionId) { - send(`data: ${JSON.stringify({ - type: 'file_tree_sync', - data: { - sessionId: data.sessionId, - fileTree: data.fileTree, - } - })} - -`); + send(createSSEMessage('file_tree_sync', { + sessionId: data.sessionId, + fileTree: data.fileTree, + })); } }; @@ -223,9 +176,7 @@ export async function GET(request: NextRequest) { // Keep connection alive with periodic heartbeat const heartbeat = setInterval(() => { - send(`data: ${JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })} - -`); + send(createSSEMessage('heartbeat', { timestamp: Date.now() })); }, 30000); // Cleanup when client disconnects diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index f97ed18..65b2525 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -184,10 +184,11 @@ function Page({ params }: PageProps) { setSandboxId(session.sandboxId); console.log('[DB] Loaded sandboxId:', session.sandboxId); - // Auto-sync filesystem when sandbox is loaded - // Block code tab until sync completes + // Auto-sync filesystem when sandbox is loaded - non-blocking async setIsSyncingFilesystem(true); - setTimeout(async () => { + // Use async IIFE instead of setTimeout for non-blocking operation + (async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s for sandbox to be ready try { const response = await fetch('/api/sync-filesystem', { method: 'POST', @@ -208,7 +209,7 @@ function Page({ params }: PageProps) { // Sync complete, unblock code tab setIsSyncingFilesystem(false); } - }, 1000); // Wait 1s for sandbox to be ready + })(); } if (session.sandboxUrl) { setSandboxUrl(session.sandboxUrl); @@ -218,15 +219,16 @@ function Page({ params }: PageProps) { setIsCheckingExpiration(true); const createdTime = new Date(session.sandboxCreatedAt).getTime(); setSandboxCreatedAt(createdTime); - // Check if already expired + // Check if already expired - non-blocking async const elapsed = Date.now() - createdTime; - setTimeout(() => { + (async () => { + await new Promise(resolve => setTimeout(resolve, 500)); // Small delay to show loader if (elapsed >= SANDBOX_EXPIRY_MS) { setIsSandboxExpired(true); console.log('[DB] Sandbox already expired'); } setIsCheckingExpiration(false); - }, 500); // Small delay to show loader + })(); } else { // Fallback: if no timestamp in DB, use current time (new sandbox, not expired) setSandboxCreatedAt(Date.now()); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 06916aa..e1cb1c1 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { Dispatch, SetStateAction, useRef, useEffect } from "react"; +import { Dispatch, SetStateAction, useRef, useEffect, memo, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { LucideSend, Bot, User } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; @@ -33,7 +33,7 @@ interface ChatPanelProps { readOnly?: boolean; // For shared sessions - disable sending messages } -export function ChatPanel({ +export const ChatPanel = memo(function ChatPanel({ messages, message, setMessage, @@ -141,7 +141,12 @@ export function ChatPanel({ 🔧 Tool Executions - {msg.toolCalls.map((toolCall, idx) => ( + {msg.toolCalls.map((toolCall, idx) => { + const argsStr = toolCall.args && Object.keys(toolCall.args).length > 0 + ? JSON.stringify(toolCall.args, null, 2) + : null; + + return (
@@ -157,10 +162,10 @@ export function ChatPanel({ ✗ Error )}
- {toolCall.args && Object.keys(toolCall.args).length > 0 && ( + {argsStr && (
Args:{' '} - {JSON.stringify(toolCall.args, null, 2).slice(0, 100)}{JSON.stringify(toolCall.args).length > 100 ? '...' : ''} + {argsStr.slice(0, 100)}{argsStr.length > 100 ? '...' : ''}
)} {toolCall.result && ( @@ -170,7 +175,8 @@ export function ChatPanel({
)} - ))} + ); + })} )} @@ -280,4 +286,4 @@ export function ChatPanel({ )} ); -} +}); diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 9368e02..a01fd4d 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, memo } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, @@ -18,22 +18,20 @@ export type FileNode = { children?: FileNode[]; }; +// Memoized file icon lookup +const fileIconMap: Record = { + 'ts': FileCode, + 'tsx': FileCode, + 'js': FileCode, + 'jsx': FileCode, + 'json': FileJson, + 'md': FileText, + 'txt': FileText, +}; + function getFileIcon(fileName: string) { const ext = fileName.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'ts': - case 'tsx': - case 'js': - case 'jsx': - return FileCode; - case 'json': - return FileJson; - case 'md': - case 'txt': - return FileText; - default: - return File; - } + return ext ? (fileIconMap[ext] || File) : File; } export interface FileTreeProps { @@ -44,7 +42,7 @@ export interface FileTreeProps { streamingFile?: string | null; } -export function FileTree({ nodes, selected, onSelect, level = 0, streamingFile }: Readonly) { +export const FileTree = memo(function FileTree({ nodes, selected, onSelect, level = 0, streamingFile }: Readonly) { // Initialize with all folders expanded by default const [open, setOpen] = useState<{ [key: string]: boolean }>(() => { const initialOpen: { [key: string]: boolean } = {}; @@ -151,4 +149,4 @@ export function FileTree({ nodes, selected, onSelect, level = 0, streamingFile } })} ); -} +}); diff --git a/src/lib/agent-memory.ts b/src/lib/agent-memory.ts index 0cf57d5..d5b775a 100644 --- a/src/lib/agent-memory.ts +++ b/src/lib/agent-memory.ts @@ -218,6 +218,8 @@ export type WorkSummary = { }; const MAX_MESSAGES = 40; // cap per session to avoid unbounded memory +const MAX_FILES_TRACKED = 50; // cap on files tracked per session +const MAX_COMPONENTS_TRACKED = 30; // cap on components tracked per session export async function getSessionMessages(sessionId: string): Promise { const memory = await getSessionMemory(sessionId, 'messages'); @@ -258,11 +260,16 @@ export async function updateWorkSummary(sessionId: string, summary: Partial pattern.test(content)); + const startsWithThinking = THINKING_PATTERNS.some(pattern => pattern.test(content)); if (startsWithThinking) { // Look for clear transition markers between thinking and answer - const transitionPatterns = [ - /\n\n(?:Here's|Here is|Now,|So,|Therefore,|Based on this,|The answer is|To summarize)/i, - /\n\n(?:Answer:|Response:|Solution:)/i, - /\n\n---+\n/, // Markdown separator - ]; - - for (const pattern of transitionPatterns) { + for (const pattern of TRANSITION_PATTERNS) { const parts = content.split(pattern); if (parts.length >= 2) { const reasoning = parts[0].trim(); diff --git a/src/trpc/routers/session.ts b/src/trpc/routers/session.ts index 912f2fb..bf17509 100644 --- a/src/trpc/routers/session.ts +++ b/src/trpc/routers/session.ts @@ -92,28 +92,43 @@ export const sessionRouter = createTRPCRouter({ deleteSession: baseProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - // Get session to retrieve sandboxId - const session = await ctx.prisma.session.findUnique({ - where: { id: input.id }, + // Use transaction to fetch and delete in one operation + const session = await ctx.prisma.$transaction(async (tx) => { + // Get session to retrieve sandboxId + const sess = await tx.session.findUnique({ + where: { id: input.id }, + select: { id: true, sandboxId: true }, + }); + + if (!sess) { + throw new Error('Session not found'); + } + + // Delete session in same transaction + await tx.session.delete({ + where: { id: input.id }, + }); + + return sess; }); - // Kill sandbox if it exists + // Kill sandbox if it exists (outside transaction to avoid blocking) if (session?.sandboxId) { - try { - const sandbox = await getSandbox(session.sandboxId); - if (sandbox) { - await sandbox.kill(); + // Fire and forget - don't await to avoid blocking response + getSandbox(session.sandboxId) + .then(sandbox => { + if (sandbox) { + return sandbox.kill(); + } + }) + .then(() => { console.log(`✅ Killed sandbox ${session.sandboxId} for session ${input.id}`); - } - } catch (error) { - console.error(`Failed to kill sandbox ${session.sandboxId}:`, error); - // Continue with session deletion even if sandbox kill fails - } + }) + .catch((error) => { + console.error(`Failed to kill sandbox ${session.sandboxId}:`, error); + }); } - await ctx.prisma.session.delete({ - where: { id: input.id }, - }); return { success: true }; }),