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,