diff --git a/electron/main.ts b/electron/main.ts index b33888e..03ea452 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -325,7 +325,40 @@ function getIconPath(): string { return path.join(process.resourcesPath, 'icon.icns'); } -function createWindow(port: number) { +/** Inline loading HTML shown while the server starts up */ +const LOADING_HTML = `data:text/html;charset=utf-8,${encodeURIComponent(` + + + + + + +
+
+

Starting CodePilot...

+
+ +`)}`; + +function createWindow(url?: string) { const windowOptions: Electron.BrowserWindowConstructorOptions = { width: 1280, height: 860, @@ -352,7 +385,7 @@ function createWindow(port: number) { mainWindow = new BrowserWindow(windowOptions); - mainWindow.loadURL(`http://127.0.0.1:${port}`); + mainWindow.loadURL(url || LOADING_HTML); if (isDev) { mainWindow.webContents.openDevTools(); @@ -730,17 +763,25 @@ app.whenReady().then(async () => { if (isDev) { port = 3000; console.log(`Dev mode: connecting to http://127.0.0.1:${port}`); + serverPort = port; + createWindow(`http://127.0.0.1:${port}`); } else { port = await getPort(); console.log(`Starting server on port ${port}...`); serverProcess = startServer(port); + serverPort = port; + + // Show window immediately with loading screen + createWindow(); + + // Wait for server in background, then navigate to real URL await waitForServer(port); console.log('Server is ready'); + if (mainWindow) { + mainWindow.loadURL(`http://127.0.0.1:${port}`); + } } - serverPort = port; - createWindow(port); - // Initialize auto-updater in packaged mode only if (!isDev && mainWindow) { initAutoUpdater(mainWindow); @@ -768,10 +809,16 @@ app.on('activate', async () => { if (!isDev && !serverProcess) { const port = await getPort(); serverProcess = startServer(port); + // Show loading window immediately + createWindow(); await waitForServer(port); serverPort = port; + if (mainWindow) { + mainWindow.loadURL(`http://127.0.0.1:${port}`); + } + } else { + createWindow(`http://127.0.0.1:${serverPort || 3000}`); } - createWindow(serverPort || 3000); // Re-attach updater to the new window if (!isDev && mainWindow) { diff --git a/package-lock.json b/package-lock.json index db992a0..d6ac675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.17.4", + "version": "0.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.17.4", + "version": "0.17.5", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", diff --git a/package.json b/package.json index 1b42d08..e0ece37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.17.4", + "version": "0.17.5", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 1311003..a557c88 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -149,7 +149,7 @@ export async function POST(request: NextRequest) { name: f.name, type: f.type, size: f.size, - data: f.data, + data: meta?.filePath ? '' : f.data, // Skip base64 data if file is already saved to disk filePath: meta?.filePath, }; }) diff --git a/src/app/api/chat/sessions/[id]/messages/route.ts b/src/app/api/chat/sessions/[id]/messages/route.ts index a2b6ba5..40ca429 100644 --- a/src/app/api/chat/sessions/[id]/messages/route.ts +++ b/src/app/api/chat/sessions/[id]/messages/route.ts @@ -2,6 +2,22 @@ import { NextRequest } from 'next/server'; import { getMessages, getSession } from '@/lib/db'; import type { MessagesResponse } from '@/types'; +/** Strip base64 `data` fields from HTML comments in message content */ +function stripFileData(content: string): string { + const match = content.match(/^/); + if (!match) return content; + try { + const files = JSON.parse(match[1]); + const cleaned = files.map((f: Record) => { + const { data, ...rest } = f; + return rest; + }); + return `${content.slice(match[0].length)}`; + } catch { + return content; + } +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -17,11 +33,16 @@ export async function GET( const limitParam = searchParams.get('limit'); const beforeParam = searchParams.get('before'); - const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 100, 1), 500) : 100; + const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 30, 1), 500) : 30; const beforeRowId = beforeParam ? parseInt(beforeParam, 10) || undefined : undefined; const { messages, hasMore } = getMessages(id, { limit, beforeRowId }); - const response: MessagesResponse = { messages, hasMore }; + // Sanitize: strip base64 data from file attachments in old messages + const sanitizedMessages = messages.map(m => ({ + ...m, + content: stripFileData(m.content), + })); + const response: MessagesResponse = { messages: sanitizedMessages, hasMore }; return Response.json(response); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch messages'; diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index c318f7b..5ff9c3b 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -118,7 +118,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { async function loadMessages() { try { - const res = await fetch(`/api/chat/sessions/${id}/messages?limit=100`); + const res = await fetch(`/api/chat/sessions/${id}/messages?limit=30`); if (cancelled) return; if (!res.ok) { if (res.status === 404) { diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index ecee208..24084b0 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -212,7 +212,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Build display content: embed file metadata as HTML comment for MessageItem to parse let displayContent = displayUserContent; if (files && files.length > 0) { - const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data })); + const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size })); displayContent = `${displayUserContent}`; } diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 11cc253..a4c1bd2 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; import type { Message, TokenUsage, FileAttachment } from '@/types'; import { Message as AIMessage, @@ -316,7 +316,7 @@ function TokenUsageDisplay({ usage }: { usage: TokenUsage }) { const COLLAPSE_HEIGHT = 300; -export function MessageItem({ message }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ message }: MessageItemProps) { const isUser = message.role === 'user'; // Collapse/expand state for long user messages (hooks must be called unconditionally) @@ -324,35 +324,42 @@ export function MessageItem({ message }: MessageItemProps) { const [isOverflowing, setIsOverflowing] = useState(false); const contentRef = useRef(null); + // Memoize expensive parsing: parseToolBlocks + pairTools + const { text, pairedTools } = useMemo(() => { + const { text, tools } = parseToolBlocks(message.content); + const pairedTools = pairTools(tools); + return { text, pairedTools }; + }, [message.content]); + + // Memoize file attachment parsing + const { files, displayText } = useMemo(() => { + if (isUser) { + const { files, text: textWithoutFiles } = parseMessageFiles(text); + return { files, displayText: textWithoutFiles }; + } + return { files: [] as FileAttachment[], displayText: text }; + }, [text, isUser]); + // Hide image-gen system notices — they exist in DB for Claude's context but shouldn't render if (isUser && message.content.startsWith('[__IMAGE_GEN_NOTICE__')) { return null; } - const { text, tools } = parseToolBlocks(message.content); - const pairedTools = pairTools(tools); - - // Parse file attachments from user messages - const { files, text: textWithoutFiles } = isUser - ? parseMessageFiles(text) - : { files: [], text }; - - const displayText = isUser ? textWithoutFiles : text; - useEffect(() => { if (isUser && contentRef.current) { setIsOverflowing(contentRef.current.scrollHeight > COLLAPSE_HEIGHT); } }, [isUser, displayText]); - let tokenUsage: TokenUsage | null = null; - if (message.token_usage) { + // Memoize token usage JSON parsing + const tokenUsage = useMemo(() => { + if (!message.token_usage) return null; try { - tokenUsage = JSON.parse(message.token_usage); + return JSON.parse(message.token_usage); } catch { - // skip + return null; } - } + }, [message.token_usage]); const timestamp = parseDBDate(message.created_at).toLocaleTimeString([], { hour: '2-digit', @@ -418,96 +425,7 @@ export function MessageItem({ message }: MessageItemProps) { )} - ) : (() => { - // Try batch-plan first (Image Agent batch mode) - const batchPlanResult = parseBatchPlan(displayText); - if (batchPlanResult) { - return ( - <> - {batchPlanResult.beforeText && {batchPlanResult.beforeText}} - - {batchPlanResult.afterText && {batchPlanResult.afterText}} - - ); - } - - // Try image-gen-result first (new direct-call format) - const genResult = parseImageGenResult(displayText); - if (genResult) { - const { result } = genResult; - if (result.status === 'generating') { - return ( - <> - {genResult.beforeText && {genResult.beforeText}} -
-
- Generating image... -
- {genResult.afterText && {genResult.afterText}} - - ); - } - if (result.status === 'error') { - return ( - <> - {genResult.beforeText && {genResult.beforeText}} -
-

{result.error || 'Image generation failed'}

-
- {genResult.afterText && {genResult.afterText}} - - ); - } - if (result.status === 'completed' && result.images && result.images.length > 0) { - return ( - <> - {genResult.beforeText && {genResult.beforeText}} - ({ - data: img.data || '', - mimeType: img.mimeType, - localPath: img.localPath, - }))} - prompt={result.prompt} - aspectRatio={result.aspectRatio} - imageSize={result.resolution} - model={result.model} - /> - {genResult.afterText && {genResult.afterText}} - - ); - } - } - - // Legacy: image-gen-request (model-dependent format, for old messages) - const parsed = parseImageGenRequest(displayText); - if (parsed) { - const refs = buildReferenceImages( - message.id, - parsed.request.useLastGenerated || false, - parsed.request.referenceImages, - ); - return ( - <> - {parsed.beforeText && {parsed.beforeText}} - 0 ? refs : undefined} - /> - {parsed.afterText && {parsed.afterText}} - - ); - } - const stripped = displayText - .replace(/```image-gen-request[\s\S]*?```/g, '') - .replace(/```image-gen-result[\s\S]*?```/g, '') - .replace(/```batch-plan[\s\S]*?```/g, '') - .trim(); - return stripped ? {stripped} : null; - })() + ) : )} @@ -519,4 +437,101 @@ export function MessageItem({ message }: MessageItemProps) {
); -} +}); + +/** + * Memoized assistant message content — avoids re-running parseBatchPlan / parseImageGenResult / + * parseImageGenRequest on every render when only unrelated props change. + */ +const AssistantContent = memo(function AssistantContent({ displayText, messageId }: { displayText: string; messageId: string }) { + return useMemo(() => { + // Try batch-plan first (Image Agent batch mode) + const batchPlanResult = parseBatchPlan(displayText); + if (batchPlanResult) { + return ( + <> + {batchPlanResult.beforeText && {batchPlanResult.beforeText}} + + {batchPlanResult.afterText && {batchPlanResult.afterText}} + + ); + } + + // Try image-gen-result first (new direct-call format) + const genResult = parseImageGenResult(displayText); + if (genResult) { + const { result } = genResult; + if (result.status === 'generating') { + return ( + <> + {genResult.beforeText && {genResult.beforeText}} +
+
+ Generating image... +
+ {genResult.afterText && {genResult.afterText}} + + ); + } + if (result.status === 'error') { + return ( + <> + {genResult.beforeText && {genResult.beforeText}} +
+

{result.error || 'Image generation failed'}

+
+ {genResult.afterText && {genResult.afterText}} + + ); + } + if (result.status === 'completed' && result.images && result.images.length > 0) { + return ( + <> + {genResult.beforeText && {genResult.beforeText}} + ({ + data: img.data || '', + mimeType: img.mimeType, + localPath: img.localPath, + }))} + prompt={result.prompt} + aspectRatio={result.aspectRatio} + imageSize={result.resolution} + model={result.model} + /> + {genResult.afterText && {genResult.afterText}} + + ); + } + } + + // Legacy: image-gen-request (model-dependent format, for old messages) + const parsed = parseImageGenRequest(displayText); + if (parsed) { + const refs = buildReferenceImages( + messageId, + parsed.request.useLastGenerated || false, + parsed.request.referenceImages, + ); + return ( + <> + {parsed.beforeText && {parsed.beforeText}} + 0 ? refs : undefined} + /> + {parsed.afterText && {parsed.afterText}} + + ); + } + const stripped = displayText + .replace(/```image-gen-request[\s\S]*?```/g, '') + .replace(/```image-gen-result[\s\S]*?```/g, '') + .replace(/```batch-plan[\s\S]*?```/g, '') + .trim(); + return stripped ? {stripped} : null; + }, [displayText, messageId]); +}); diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 5a33777..4b5a18e 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -2,7 +2,7 @@ import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import { Delete02Icon, @@ -213,37 +213,53 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { }); }, []); + // AbortController ref for cancelling in-flight requests + const abortRef = useRef(null); + const debounceRef = useRef | null>(null); + const fetchSessions = useCallback(async () => { + // Cancel any in-flight request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; try { - const res = await fetch("/api/chat/sessions"); + const res = await fetch("/api/chat/sessions", { signal: controller.signal }); if (res.ok) { const data = await res.json(); setSessions(data.sessions || []); } - } catch { - // API may not be available yet + } catch (e) { + // Ignore abort errors; log others + if (e instanceof DOMException && e.name === 'AbortError') return; } }, []); - useEffect(() => { - fetchSessions(); + const debouncedFetchSessions = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + fetchSessions(); + }, 300); }, [fetchSessions]); - // Refresh session list when navigating + // Fetch on mount useEffect(() => { fetchSessions(); - }, [pathname, fetchSessions]); + return () => { + abortRef.current?.abort(); + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [fetchSessions]); - // Refresh session list when a session is created or updated + // Refresh session list when a session is created or updated (debounced) useEffect(() => { - const handler = () => fetchSessions(); + const handler = () => debouncedFetchSessions(); window.addEventListener("session-created", handler); window.addEventListener("session-updated", handler); return () => { window.removeEventListener("session-created", handler); window.removeEventListener("session-updated", handler); }; - }, [fetchSessions]); + }, [debouncedFetchSessions]); const handleDeleteSession = async ( e: React.MouseEvent,