From e5d17bfad3900b49761b9d49c111dde74af3c5c0 Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Sat, 20 Dec 2025 17:28:03 +0100 Subject: [PATCH] perf: Major performance overhaul with virtual scrolling and context splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - Virtual Scrolling: - Add @tanstack/react-virtual for efficient message list rendering - Only render visible messages instead of entire history - Fix auto-scroll using native scrollTop instead of unreliable virtualizer Phase 2 - Context Optimization: - Split monolithic SessionContext into 4 specialized contexts - MessagesContext, SessionsContext, SettingsContext, UIContext - Prevents unnecessary re-renders across unrelated components Phase 3 - Compression & Cleanup: - Enable Brotli compression (~23% smaller than gzip) - Switch to fholzer/nginx-brotli:v1.28.0 image - Add automatic upload cleanup for idle sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/.npmrc | 5 + backend/server.js | 61 +- frontend/.npmrc | 5 + frontend/Dockerfile | 7 +- frontend/nginx.conf | 10 +- frontend/package-lock.json | 26 + frontend/package.json | 1 + frontend/src/App.jsx | 26 +- frontend/src/components/ChatPanel.jsx | 9 +- frontend/src/components/MessageList.jsx | 171 +++-- frontend/src/components/Sidebar.jsx | 19 +- frontend/src/components/SplitLayout.jsx | 6 +- frontend/src/components/TabBar.jsx | 16 +- frontend/src/contexts/MessagesContext.jsx | 132 ++++ ...SessionContext.jsx => SessionsContext.jsx} | 694 ++++++------------ frontend/src/contexts/SettingsContext.jsx | 55 ++ frontend/src/contexts/UIContext.jsx | 148 ++++ 17 files changed, 827 insertions(+), 564 deletions(-) create mode 100644 backend/.npmrc create mode 100644 frontend/.npmrc create mode 100644 frontend/src/contexts/MessagesContext.jsx rename frontend/src/contexts/{SessionContext.jsx => SessionsContext.jsx} (59%) create mode 100644 frontend/src/contexts/SettingsContext.jsx create mode 100644 frontend/src/contexts/UIContext.jsx diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 0000000..1452fc7 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1,5 @@ +fetch-timeout=300000 +fetch-retries=3 +fetch-retry-factor=2 +fetch-retry-mintimeout=10000 +fetch-retry-maxtimeout=60000 diff --git a/backend/server.js b/backend/server.js index 87e0fed..b55e563 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,7 +4,7 @@ import { createServer } from 'http'; import { spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import cors from 'cors'; -import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, appendFileSync } from 'fs'; +import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'; import { join, basename, extname } from 'path'; import multer from 'multer'; import session from 'express-session'; @@ -41,6 +41,32 @@ const DEBUG = process.env.DEBUG === 'true'; const UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +// History cache with 30s TTL +const historyCache = new Map(); +const HISTORY_CACHE_TTL = 30 * 1000; // 30 seconds + +function getCachedHistory(cacheKey) { + const cached = historyCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < HISTORY_CACHE_TTL) { + return cached.data; + } + historyCache.delete(cacheKey); + return null; +} + +function setCachedHistory(cacheKey, data) { + historyCache.set(cacheKey, { data, timestamp: Date.now() }); + // Clean up old entries periodically + if (historyCache.size > 100) { + const now = Date.now(); + for (const [key, value] of historyCache) { + if (now - value.timestamp > HISTORY_CACHE_TTL) { + historyCache.delete(key); + } + } + } +} + // Allowed file types const ALLOWED_TYPES = { // Images @@ -99,6 +125,19 @@ const upload = multer({ } }); +// Cleanup uploads for a session +function cleanupSessionUploads(sessionId) { + try { + const sessionDir = join(UPLOAD_DIR, sessionId); + if (existsSync(sessionDir)) { + rmSync(sessionDir, { recursive: true, force: true }); + console.log(`[Cleanup] Removed upload directory for session: ${sessionId}`); + } + } catch (err) { + console.error(`[Cleanup] Failed to remove uploads for session ${sessionId}:`, err.message); + } +} + // Load hosts configuration const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json'; let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } }; @@ -181,6 +220,8 @@ setInterval(() => { } } sessions.delete(id); + // Also cleanup uploads + cleanupSessionUploads(id); } } }, 60 * 60 * 1000); // Check hourly @@ -548,6 +589,14 @@ app.get('/api/history/:project', requireAuth, async (req, res) => { const isSSH = host?.connection?.type === 'ssh'; console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`); + // Check cache first + const cacheKey = `${hostId || 'local'}:${projectPath}`; + const cached = getCachedHistory(cacheKey); + if (cached) { + console.log(`[History] Cache hit for ${cacheKey}`); + return res.json(cached); + } + // Convert project path to Claude's folder naming convention const projectFolder = projectPath.replace(/\//g, '-'); @@ -581,9 +630,11 @@ app.get('/api/history/:project', requireAuth, async (req, res) => { const sessionId = basename(latestFile).replace('.jsonl', ''); const messages = parseHistoryContent(content); + const result = { messages, sessionId, source: 'ssh' }; console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`); - return res.json({ messages, sessionId, source: 'ssh' }); + setCachedHistory(cacheKey, result); + return res.json(result); } catch (sshErr) { console.error('SSH history fetch error:', sshErr.message); return res.json({ messages: [], sessionId: null, error: sshErr.message }); @@ -617,8 +668,10 @@ app.get('/api/history/:project', requireAuth, async (req, res) => { const sessionId = latestFile.name.replace('.jsonl', ''); const content = readFileSync(latestFile.path, 'utf-8'); const messages = parseHistoryContent(content); + const result = { messages, sessionId }; - res.json({ messages, sessionId }); + setCachedHistory(cacheKey, result); + res.json(result); } catch (err) { console.error('Error reading history:', err); res.status(500).json({ error: err.message }); @@ -1342,6 +1395,8 @@ wss.on('connection', async (ws, req) => { claudeProcess.kill(); sessions.delete(sessionId); } + // Cleanup uploaded files for this session + cleanupSessionUploads(sessionId); }); ws.on('error', (err) => { diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..1452fc7 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,5 @@ +fetch-timeout=300000 +fetch-retries=3 +fetch-retry-factor=2 +fetch-retry-mintimeout=10000 +fetch-retry-maxtimeout=60000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3e01f2b..7ff18b3 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -25,8 +25,8 @@ COPY . . # Build the app RUN npm run build -# Production stage -FROM nginx:alpine +# Production stage - using nginx with brotli support +FROM fholzer/nginx-brotli:v1.28.0 # Copy built files COPY --from=builder /app/dist /usr/share/nginx/html @@ -36,4 +36,5 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +# fholzer/nginx-brotli has ENTRYPOINT ["nginx"], so only pass args +CMD ["-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 38e3853..499a804 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,10 +4,15 @@ server { root /usr/share/nginx/html; index index.html; - # Gzip + # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + # Brotli compression (requires fholzer/nginx-brotli image) + brotli on; + brotli_comp_level 6; + brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/wasm; + # Backend API proxy (same network namespace via netbird-client) # Using 127.0.0.1 instead of localhost to force IPv4 (avoids IPv6 connection issues) # Note: proxy_pass without URI preserves URL encoding (important for paths with %2F) @@ -47,6 +52,9 @@ server { proxy_set_header X-Forwarded-Proto https; proxy_read_timeout 86400; proxy_send_timeout 86400; + # Disable buffering for real-time streaming + proxy_buffering off; + proxy_cache off; } # SPA routing for frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4ad595..9d89e38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "claude-web-ui-frontend", "version": "1.0.0", "dependencies": { + "@tanstack/react-virtual": "^3.10.9", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1124,6 +1125,31 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz", + "integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==", + "dependencies": { + "@tanstack/virtual-core": "3.13.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", + "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0d8dc2a..ad73723 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-virtual": "^3.10.9", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cb727a9..7cd4a75 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,10 @@ import { useState, useCallback, useEffect } from 'react'; import { AuthProvider, useAuth } from './contexts/AuthContext'; -import { SessionProvider, useSessionManager } from './contexts/SessionContext'; import { HostProvider } from './contexts/HostContext'; +import { SettingsProvider } from './contexts/SettingsContext'; +import { UIProvider, useUI } from './contexts/UIContext'; +import { MessagesProvider } from './contexts/MessagesContext'; +import { SessionsProvider, useSessions } from './contexts/SessionsContext'; import { Sidebar } from './components/Sidebar'; import { TabBar } from './components/TabBar'; import { ChatPanel } from './components/ChatPanel'; @@ -10,13 +13,8 @@ import { LoginPage } from './components/LoginPage'; import { Menu, Loader2 } from 'lucide-react'; function AppContent() { - const { - sessions, - tabOrder, - splitSessions, - focusedSessionId, - createSession, - } = useSessionManager(); + const { sessions, createSession } = useSessions(); + const { tabOrder, splitSessions, focusedSessionId } = useUI(); const [sidebarOpen, setSidebarOpen] = useState(true); @@ -130,9 +128,15 @@ function AuthenticatedApp() { return ( - - - + + + + + + + + + ); } diff --git a/frontend/src/components/ChatPanel.jsx b/frontend/src/components/ChatPanel.jsx index 6c713ca..5ca59ad 100644 --- a/frontend/src/components/ChatPanel.jsx +++ b/frontend/src/components/ChatPanel.jsx @@ -4,7 +4,8 @@ import { ChatInput } from './ChatInput'; import { StatusBar } from './StatusBar'; import { PermissionDialog } from './PermissionDialog'; import { HelpDialog } from './HelpDialog'; -import { useSessionManager } from '../contexts/SessionContext'; +import { useSessions } from '../contexts/SessionsContext'; +import { useMessages } from '../contexts/MessagesContext'; import { Bot } from 'lucide-react'; // Wrapper to compute placeholder inside and prevent parent re-renders from affecting input @@ -99,16 +100,16 @@ const ErrorBanner = memo(function ErrorBanner({ error, onClear }) { function useMemoizedSession(sessionId) { const { sessions, - sessionMessages, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, - clearMessages, setCompacting, changePermissionMode, respondToPermission, - } = useSessionManager(); + } = useSessions(); + + const { sessionMessages, clearMessages } = useMessages(); const session = sessions[sessionId]; const messages = sessionMessages[sessionId] || []; diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 0d1d8f9..6219e1f 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense } from 'react'; +import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense, useLayoutEffect } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import TextareaAutosize from 'react-textarea-autosize'; import { User, Bot, Terminal, CheckCircle, AlertCircle, Info, @@ -177,7 +178,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o const { hosts } = useHosts(); const hostConfig = hosts[hostId] || null; const containerRef = useRef(null); - const messagesEndRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); const prevMessageCount = useRef(messages.length); @@ -203,30 +203,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o } }, [checkIfAtBottom]); - // Auto-scroll on new messages or content updates - useEffect(() => { - if (!userScrolledAway.current && containerRef.current) { - // Use scrollTop directly for more reliable scrolling - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } else if (userScrolledAway.current) { - const newCount = messages.length - prevMessageCount.current; - if (newCount > 0) { - setNewMessageCount(prev => prev + newCount); - } - } - prevMessageCount.current = messages.length; - }, [messages]); // Watch entire messages array for content updates - - // Scroll to bottom function - const scrollToBottom = useCallback(() => { - userScrolledAway.current = false; - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - setShowScrollButton(false); - setNewMessageCount(0); - }, []); - // Cache for incremental updates - avoid full rebuild on streaming content changes const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() }); @@ -295,6 +271,82 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o return result; }, [messages]); + // Virtualizer for efficient rendering of large message lists + // Estimate height based on message type + const estimateSize = useCallback((index) => { + const msg = processedMessages[index]; + if (!msg) return 100; + switch (msg.type) { + case 'user': return 80; + case 'assistant': return 200; + case 'tool_use': return 150; + case 'tool_result': return 120; + case 'system': return 40; + case 'error': return 80; + default: return 100; + } + }, [processedMessages]); + + const virtualizer = useVirtualizer({ + count: processedMessages.length + (isProcessing ? 1 : 0), // +1 for processing indicator + getScrollElement: () => containerRef.current, + estimateSize, + overscan: 5, // Render 5 extra items above/below viewport + measureElement: (element) => { + // Measure actual element height for dynamic sizing + return element?.getBoundingClientRect().height ?? estimateSize(0); + }, + }); + + // Auto-scroll to bottom when new messages arrive or content changes (if not scrolled away) + useLayoutEffect(() => { + // Skip if user manually scrolled away + if (userScrolledAway.current) { + const newCount = messages.length - prevMessageCount.current; + if (newCount > 0) { + setNewMessageCount(prev => prev + newCount); + } + prevMessageCount.current = messages.length; + return; + } + + // Use native scroll for reliability - virtualizer.scrollToIndex can be unreliable + if (containerRef.current && processedMessages.length > 0) { + // Small delay to let DOM update after content change + requestAnimationFrame(() => { + if (containerRef.current && !userScrolledAway.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }); + } + prevMessageCount.current = messages.length; + }, [messages, processedMessages.length, isProcessing]); + + // Also scroll on streaming content updates (last message content change) + const lastMessageContent = messages[messages.length - 1]?.content; + useLayoutEffect(() => { + if (!userScrolledAway.current && containerRef.current && lastMessageContent) { + requestAnimationFrame(() => { + if (containerRef.current && !userScrolledAway.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }); + } + }, [lastMessageContent]); + + // Scroll to bottom function - use native scroll for reliability + const scrollToBottomVirtual = useCallback(() => { + userScrolledAway.current = false; + if (containerRef.current) { + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: 'smooth' + }); + } + setShowScrollButton(false); + setNewMessageCount(0); + }, []); + if (messages.length === 0) { return (
@@ -310,36 +362,67 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o ); } + const items = virtualizer.getVirtualItems(); + return (
- {processedMessages.map((message, index) => ( - - ))} + {/* Virtual list container */} +
+ {items.map((virtualItem) => { + const isProcessingIndicator = virtualItem.index === processedMessages.length; - {/* Processing indicator */} - {isProcessing && ( -
- -
- - - -
-
- )} - -
+ return ( +
+ {isProcessingIndicator ? ( + // Processing indicator +
+ +
+ + + +
+
+ ) : ( + + )} +
+ ); + })} +
{/* Floating scroll-to-bottom button */} {showScrollButton && (