Files
CodePilot/src/components/chat/ChatView.tsx
T
7418andClaude Opus 4.6 132fdeb255 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>
2026-02-27 15:00:03 +08:00

411 lines
17 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
import {
startStream,
stopStream,
subscribe,
getSnapshot,
respondToPermission,
clearSnapshot,
} from '@/lib/stream-session-manager';
interface ChatViewProps {
sessionId: string;
initialMessages?: Message[];
initialHasMore?: boolean;
modelName?: string;
initialMode?: string;
providerId?: string;
}
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
const loadingMoreRef = useRef(false);
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Stream snapshot from the manager — drives all streaming UI
const [streamSnapshot, setStreamSnapshot] = useState<SessionStreamSnapshot | null>(
() => getSnapshot(sessionId)
);
// Derive rendering state from snapshot (backward-compatible with MessageList props)
const isStreaming = streamSnapshot?.phase === 'active';
const streamingContent = streamSnapshot?.streamingContent ?? '';
const toolUses = streamSnapshot?.toolUses ?? [];
const toolResults = streamSnapshot?.toolResults ?? [];
const streamingToolOutput = streamSnapshot?.streamingToolOutput ?? '';
const statusText = streamSnapshot?.statusText;
const pendingPermission = streamSnapshot?.pendingPermission ?? null;
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
const pendingImageNoticesRef = useRef<string[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
// Try to switch SDK permission mode in real-time (works if streaming)
fetch('/api/chat/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, mode: newMode }),
}).catch(() => { /* silent — will apply on next message */ });
}
}, [sessionId]);
const handleProviderModelChange = useCallback((newProviderId: string, model: string) => {
setCurrentProviderId(newProviderId);
setCurrentModel(model);
}, []);
// Subscribe to stream-session-manager for this session.
// On unmount we only unsubscribe — we do NOT abort the stream.
useEffect(() => {
// Restore snapshot if stream is already active (e.g., user switched away and back)
const existing = getSnapshot(sessionId);
if (existing) {
setStreamSnapshot(existing);
if (existing.phase === 'active') {
setStreamingSessionId(sessionId);
}
if (existing.pendingPermission && !existing.permissionResolved) {
setPendingApprovalSessionId(sessionId);
}
} else {
setStreamSnapshot(null);
}
const unsubscribe = subscribe(sessionId, (event) => {
setStreamSnapshot(event.snapshot);
// Sync panel state
if (event.type === 'phase-changed') {
if (event.snapshot.phase === 'active') {
setStreamingSessionId(sessionId);
} else {
setStreamingSessionId('');
setPendingApprovalSessionId('');
}
}
if (event.type === 'permission-request') {
setPendingApprovalSessionId(sessionId);
}
if (event.type === 'completed') {
setStreamingSessionId('');
setPendingApprovalSessionId('');
// Append the final assistant message to the messages list
const finalContent = event.snapshot.finalMessageContent;
if (finalContent) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: finalContent,
created_at: new Date().toISOString(),
token_usage: event.snapshot.tokenUsage ? JSON.stringify(event.snapshot.tokenUsage) : null,
};
// Transfer pending reference images to this message ID
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
}
// Clear the snapshot from the manager since we've consumed it
clearSnapshot(sessionId);
}
});
return () => {
unsubscribe();
// Do NOT abort — stream continues in the manager
};
}, [sessionId, setStreamingSessionId, setPendingApprovalSessionId]);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
initializedRef.current = true;
setMessages(initialMessages);
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
// Sync hasMore when initial data loads
useEffect(() => {
setHasMore(initialHasMore);
}, [initialHasMore]);
const loadEarlierMessages = useCallback(async () => {
// Use ref as atomic lock to prevent double-fetch from rapid clicks
if (loadingMoreRef.current || !hasMore || messages.length === 0) return;
loadingMoreRef.current = true;
setLoadingMore(true);
try {
// Use _rowid of the earliest message as cursor
const earliest = messages[0];
const earliestRowId = (earliest as Message & { _rowid?: number })._rowid;
if (!earliestRowId) return;
const res = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100&before=${earliestRowId}`);
if (!res.ok) return;
const data: MessagesResponse = await res.json();
setHasMore(data.hasMore ?? false);
if (data.messages.length > 0) {
setMessages(prev => [...data.messages, ...prev]);
}
} finally {
loadingMoreRef.current = false;
setLoadingMore(false);
}
}, [sessionId, messages, hasMore]);
// Stop streaming — delegates to manager
const stopStreaming = useCallback(() => {
stopStream(sessionId);
}, [sessionId]);
// Permission response — delegates to manager
const handlePermissionResponse = useCallback(
async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>) => {
setPendingApprovalSessionId('');
await respondToPermission(sessionId, decision, updatedInput);
},
[sessionId, setPendingApprovalSessionId]
);
// Send message — delegates stream management to the manager
const sendMessage = useCallback(
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
if (isStreaming) return;
// Use displayOverride for UI if provided (e.g. image-gen skill injection hides the skill prompt)
const displayUserContent = displayOverride || content;
// 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 }));
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayUserContent}`;
}
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content: displayContent,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
// Flush pending image notices
const notices = pendingImageNoticesRef.current.length > 0
? [...pendingImageNoticesRef.current]
: undefined;
if (notices) {
pendingImageNoticesRef.current = [];
}
// Delegate to stream session manager
startStream({
sessionId,
content,
mode,
model: currentModel,
providerId: currentProviderId,
files,
systemPromptAppend,
pendingImageNotices: notices,
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
},
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
});
},
[sessionId, isStreaming, mode, currentModel, currentProviderId, handleModeChange]
);
// Keep sendMessageRef in sync so timeout auto-retry can call it
sendMessageRef.current = sendMessage;
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
// Also clear database messages and reset SDK session
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear_messages: true }),
}).catch(() => { /* silent */ });
}
break;
case '/cost': {
// Aggregate token usage from all messages in this session
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalCost = 0;
let turnCount = 0;
for (const msg of messages) {
if (msg.token_usage) {
try {
const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
if (usage.cost_usd) totalCost += usage.cost_usd;
turnCount++;
} catch { /* skip */ }
}
}
const totalTokens = totalInput + totalOutput;
let content: string;
if (turnCount === 0) {
content = `## Token Usage\n\nNo token usage data yet. Send a message first.`;
} else {
content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`;
}
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
// This shouldn't be reached since non-immediate commands are handled via badge
sendMessage(command);
}
}, [sessionId, sendMessage]);
// Listen for image generation completion — persist notice to DB and queue for next user message.
// The notice is NOT sent as a separate LLM turn (avoids permission popups).
// Instead it's flushed into the next user message via pendingImageNoticesRef.
// MessageItem hides messages matching this prefix so the user doesn't see them.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail) return;
const paths = (detail.images || [])
.map((img: { localPath?: string }) => img.localPath)
.filter(Boolean);
const pathInfo = paths.length > 0 ? `\nGenerated image file paths:\n${paths.map((p: string) => `- ${p}`).join('\n')}` : '';
const notice = `[Image generation completed]\n- Prompt: "${detail.prompt}"\n- Aspect ratio: ${detail.aspectRatio}\n- Resolution: ${detail.resolution}${pathInfo}`;
// Store generated image paths so subsequent edits can use them as reference
if (paths.length > 0) {
setLastGeneratedImages(paths);
}
// Queue for next user message so the LLM gets the context
pendingImageNoticesRef.current.push(notice);
// Also persist to DB for history reload
const dbNotice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''}]`;
fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, role: 'user', content: dbNotice }),
}).catch(() => {});
};
window.addEventListener('image-gen-completed', handler);
return () => window.removeEventListener('image-gen-completed', handler);
}, [sessionId]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
onForceStop={stopStreaming}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadEarlierMessages}
/>
{/* Batch image generation panels — shown above the input area */}
<BatchExecutionDashboard />
<BatchContextSync />
<MessageInput
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
sessionId={sessionId}
modelName={currentModel}
onModelChange={setCurrentModel}
providerId={currentProviderId}
onProviderModelChange={handleProviderModelChange}
workingDirectory={workingDirectory}
mode={mode}
onModeChange={handleModeChange}
/>
</div>
);
}