From 1b294fe1c27771a4cb6f134dafd5df06eaf3dfc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:34:25 +0000 Subject: [PATCH 1/4] Initial plan From 5b39ba276ed076ddedc9343d0b7cb0f1532dec34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:37:27 +0000 Subject: [PATCH 2/4] Add React.memo to components, pagination to API, and fix memory leaks Co-authored-by: kaifcoder <57701861+kaifcoder@users.noreply.github.com> --- src/app/api/sessions/route.ts | 41 ++++++++++++++++++++++++++++------- src/components/ChatPanel.tsx | 21 ++++++++++-------- src/components/FileTree.tsx | 37 ++++++++++++++++--------------- src/lib/agent-memory.ts | 13 ++++++++--- 4 files changed, 74 insertions(+), 38 deletions(-) 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/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 06916aa..bcbfa0a 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, @@ -157,12 +157,15 @@ export function ChatPanel({ ✗ Error )} - {toolCall.args && Object.keys(toolCall.args).length > 0 && ( -
- Args:{' '} - {JSON.stringify(toolCall.args, null, 2).slice(0, 100)}{JSON.stringify(toolCall.args).length > 100 ? '...' : ''} -
- )} + {toolCall.args && Object.keys(toolCall.args).length > 0 && (() => { + const argsStr = JSON.stringify(toolCall.args, null, 2); + return ( +
+ Args:{' '} + {argsStr.slice(0, 100)}{argsStr.length > 100 ? '...' : ''} +
+ ); + })()} {toolCall.result && (
Result:{' '} @@ -280,4 +283,4 @@ export function ChatPanel({ )}
); -} +}); diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 9368e02..846f2e9 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, memo, useMemo } 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 } = {}; @@ -86,7 +84,10 @@ export function FileTree({ nodes, selected, onSelect, level = 0, streamingFile } return (
{nodes.map((node) => { - const FileIconComponent = node.type === "file" ? getFileIcon(node.name) : null; + const FileIconComponent = useMemo(() => + node.type === "file" ? getFileIcon(node.name) : null, + [node.type, node.name] + ); return (
@@ -151,4 +152,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 Date: Thu, 22 Jan 2026 10:39:24 +0000 Subject: [PATCH 3/4] Optimize SSE handler, regex patterns, async operations, and database queries Co-authored-by: kaifcoder <57701861+kaifcoder@users.noreply.github.com> --- src/app/api/stream/route.ts | 171 ++++++++++++--------------------- src/app/chat/[id]/page.tsx | 16 +-- src/lib/nextjs-coding-agent.ts | 30 +++--- src/trpc/routers/session.ts | 47 ++++++--- 4 files changed, 116 insertions(+), 148 deletions(-) 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/lib/nextjs-coding-agent.ts b/src/lib/nextjs-coding-agent.ts index ae900d7..0a9f7e3 100644 --- a/src/lib/nextjs-coding-agent.ts +++ b/src/lib/nextjs-coding-agent.ts @@ -150,27 +150,27 @@ async function* typewriterEffect(content: string): AsyncGenerator 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 }; }), From 687fc3be26f4dc931509e8e9f513222a2f9130f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:42:40 +0000 Subject: [PATCH 4/4] Address code review feedback: simplify IIFE and remove unnecessary useMemo Co-authored-by: kaifcoder <57701861+kaifcoder@users.noreply.github.com> --- src/components/ChatPanel.tsx | 25 ++++++++++++++----------- src/components/FileTree.tsx | 7 ++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index bcbfa0a..e1cb1c1 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -141,7 +141,12 @@ export const ChatPanel = memo(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,15 +162,12 @@ export const ChatPanel = memo(function ChatPanel({ ✗ Error )}
- {toolCall.args && Object.keys(toolCall.args).length > 0 && (() => { - const argsStr = JSON.stringify(toolCall.args, null, 2); - return ( -
- Args:{' '} - {argsStr.slice(0, 100)}{argsStr.length > 100 ? '...' : ''} -
- ); - })()} + {argsStr && ( +
+ Args:{' '} + {argsStr.slice(0, 100)}{argsStr.length > 100 ? '...' : ''} +
+ )} {toolCall.result && (
Result:{' '} @@ -173,7 +175,8 @@ export const ChatPanel = memo(function ChatPanel({
)}
- ))} + ); + })}
)} diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 846f2e9..a01fd4d 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, memo, useMemo } from "react"; +import { useState, useEffect, memo } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, @@ -84,10 +84,7 @@ export const FileTree = memo(function FileTree({ nodes, selected, onSelect, leve return (
{nodes.map((node) => { - const FileIconComponent = useMemo(() => - node.type === "file" ? getFileIcon(node.name) : null, - [node.type, node.name] - ); + const FileIconComponent = node.type === "file" ? getFileIcon(node.name) : null; return (