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>
411 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|