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 && (