perf: P0 performance optimizations — memo, base64 cleanup, startup speed, bump v0.17.5

Six independent fixes targeting startup lag, streaming jank, and excessive payloads:

Step 0 — MessageItem React.memo + useMemo (MessageItem.tsx)
- Wrap MessageItem with React.memo so historical messages skip re-render
  when `message` ref is stable during streaming
- Add useMemo for parseToolBlocks + pairTools (deps: message.content),
  parseMessageFiles (deps: text), token_usage JSON.parse (deps: message.token_usage)
- Extract assistant content rendering into separate memo'd AssistantContent component
  to cache parseBatchPlan / parseImageGen chain (deps: displayText, messageId)
- Root cause: 50 msgs × 10 SSE events/s = 500 full parses/s causing UI jank

Step 1 — Strip base64 from new messages (ChatView.tsx, route.ts)
- ChatView: remove `data: f.data` from fileMeta in display content HTML comment
- route.ts: skip base64 data in fileAttachments when filePath exists (file already on disk)
- Root cause: base64 image data inflated message content to MB-level

Step 2 — Sanitize old messages in API (messages/route.ts)
- Add stripFileData() to strip base64 `data` fields from <!--files:...--> comments
  before returning messages from GET /api/chat/sessions/:id/messages
- Handles legacy data already stored with base64

Step 3 — Reduce initial fetch limit 100 → 30 (page.tsx, messages/route.ts)
- Default limit changed from 100 to 30 on both frontend and backend
- "Load more" functionality unchanged

Step 4 — ChatListPanel dedup requests (ChatListPanel.tsx)
- Remove duplicate pathname-dependent useEffect (was causing 2 fetches per navigation)
- Add AbortController to cancel in-flight requests on new fetch
- Add 300ms debounce for event-driven fetches (session-created/session-updated)

Step 5 — Electron startup: show window before server ready (electron/main.ts)
- createWindow() now accepts optional URL, defaults to inline loading HTML
- Loading page: dark theme, centered spinner, "Starting CodePilot..." text
- Startup: startServer() → createWindow(loading) → waitForServer() → loadURL(real)
- Also updated activate handler for same early-window pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-27 15:00:03 +08:00
1 parent c14fdb07ae
commit 132fdeb255
9 files changed
+232 -133

No files matched your search

+1 -1
View File
@@ -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 = `<!--files:${JSON.stringify(fileMeta)}-->${displayUserContent}`;
}
+123 -108
View File
@@ -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<HTMLDivElement>(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<TokenUsage | null>(() => {
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) {
</button>
)}
</div>
) : (() => {
// Try batch-plan first (Image Agent batch mode)
const batchPlanResult = parseBatchPlan(displayText);
if (batchPlanResult) {
return (
<>
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={message.id} />
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
</>
);
}
// 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 && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="flex items-center gap-2 py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Generating image...</span>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'error') {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'completed' && result.images && result.images.length > 0) {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<ImageGenCard
images={result.images.map(img => ({
data: img.data || '',
mimeType: img.mimeType,
localPath: img.localPath,
}))}
prompt={result.prompt}
aspectRatio={result.aspectRatio}
imageSize={result.resolution}
model={result.model}
/>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
}
// 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 && <MessageResponse>{parsed.beforeText}</MessageResponse>}
<ImageGenConfirmation
messageId={message.id}
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImages={refs.length > 0 ? refs : undefined}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
);
}
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 ? <MessageResponse>{stripped}</MessageResponse> : null;
})()
) : <AssistantContent displayText={displayText} messageId={message.id} />
)}
</MessageContent>
@@ -519,4 +437,101 @@ export function MessageItem({ message }: MessageItemProps) {
</div>
</AIMessage>
);
}
});
/**
* 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 && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={messageId} />
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
</>
);
}
// 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 && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="flex items-center gap-2 py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Generating image...</span>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'error') {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
</div>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
if (result.status === 'completed' && result.images && result.images.length > 0) {
return (
<>
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
<ImageGenCard
images={result.images.map(img => ({
data: img.data || '',
mimeType: img.mimeType,
localPath: img.localPath,
}))}
prompt={result.prompt}
aspectRatio={result.aspectRatio}
imageSize={result.resolution}
model={result.model}
/>
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
</>
);
}
}
// 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 && <MessageResponse>{parsed.beforeText}</MessageResponse>}
<ImageGenConfirmation
messageId={messageId}
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImages={refs.length > 0 ? refs : undefined}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
);
}
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 ? <MessageResponse>{stripped}</MessageResponse> : null;
}, [displayText, messageId]);
});
+27 -11
View File
@@ -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<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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,