perf: Major performance overhaul with virtual scrolling and context splitting
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 <noreply@anthropic.com>
This commit is contained in:
5
backend/.npmrc
Normal file
5
backend/.npmrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fetch-timeout=300000
|
||||||
|
fetch-retries=3
|
||||||
|
fetch-retry-factor=2
|
||||||
|
fetch-retry-mintimeout=10000
|
||||||
|
fetch-retry-maxtimeout=60000
|
||||||
@@ -4,7 +4,7 @@ import { createServer } from 'http';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import cors from 'cors';
|
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 { join, basename, extname } from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import session from 'express-session';
|
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 UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads';
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
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
|
// Allowed file types
|
||||||
const ALLOWED_TYPES = {
|
const ALLOWED_TYPES = {
|
||||||
// Images
|
// 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
|
// Load hosts configuration
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
||||||
let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } };
|
let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } };
|
||||||
@@ -181,6 +220,8 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
|
// Also cleanup uploads
|
||||||
|
cleanupSessionUploads(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60 * 60 * 1000); // Check hourly
|
}, 60 * 60 * 1000); // Check hourly
|
||||||
@@ -548,6 +589,14 @@ app.get('/api/history/:project', requireAuth, async (req, res) => {
|
|||||||
const isSSH = host?.connection?.type === 'ssh';
|
const isSSH = host?.connection?.type === 'ssh';
|
||||||
console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`);
|
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
|
// Convert project path to Claude's folder naming convention
|
||||||
const projectFolder = projectPath.replace(/\//g, '-');
|
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 sessionId = basename(latestFile).replace('.jsonl', '');
|
||||||
const messages = parseHistoryContent(content);
|
const messages = parseHistoryContent(content);
|
||||||
|
const result = { messages, sessionId, source: 'ssh' };
|
||||||
|
|
||||||
console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`);
|
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) {
|
} catch (sshErr) {
|
||||||
console.error('SSH history fetch error:', sshErr.message);
|
console.error('SSH history fetch error:', sshErr.message);
|
||||||
return res.json({ messages: [], sessionId: null, 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 sessionId = latestFile.name.replace('.jsonl', '');
|
||||||
const content = readFileSync(latestFile.path, 'utf-8');
|
const content = readFileSync(latestFile.path, 'utf-8');
|
||||||
const messages = parseHistoryContent(content);
|
const messages = parseHistoryContent(content);
|
||||||
|
const result = { messages, sessionId };
|
||||||
|
|
||||||
res.json({ messages, sessionId });
|
setCachedHistory(cacheKey, result);
|
||||||
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error reading history:', err);
|
console.error('Error reading history:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -1342,6 +1395,8 @@ wss.on('connection', async (ws, req) => {
|
|||||||
claudeProcess.kill();
|
claudeProcess.kill();
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
// Cleanup uploaded files for this session
|
||||||
|
cleanupSessionUploads(sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
ws.on('error', (err) => {
|
||||||
|
|||||||
5
frontend/.npmrc
Normal file
5
frontend/.npmrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fetch-timeout=300000
|
||||||
|
fetch-retries=3
|
||||||
|
fetch-retry-factor=2
|
||||||
|
fetch-retry-mintimeout=10000
|
||||||
|
fetch-retry-maxtimeout=60000
|
||||||
@@ -25,8 +25,8 @@ COPY . .
|
|||||||
# Build the app
|
# Build the app
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage - using nginx with brotli support
|
||||||
FROM nginx:alpine
|
FROM fholzer/nginx-brotli:v1.28.0
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
@@ -36,4 +36,5 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
# fholzer/nginx-brotli has ENTRYPOINT ["nginx"], so only pass args
|
||||||
|
CMD ["-g", "daemon off;"]
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Gzip
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
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)
|
# Backend API proxy (same network namespace via netbird-client)
|
||||||
# Using 127.0.0.1 instead of localhost to force IPv4 (avoids IPv6 connection issues)
|
# 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)
|
# 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_set_header X-Forwarded-Proto https;
|
||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
proxy_send_timeout 86400;
|
proxy_send_timeout 86400;
|
||||||
|
# Disable buffering for real-time streaming
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA routing for frontend
|
# SPA routing for frontend
|
||||||
|
|||||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "claude-web-ui-frontend",
|
"name": "claude-web-ui-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.10.9",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -1124,6 +1125,31 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.10.9",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
|
|
||||||
import { HostProvider } from './contexts/HostContext';
|
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 { Sidebar } from './components/Sidebar';
|
||||||
import { TabBar } from './components/TabBar';
|
import { TabBar } from './components/TabBar';
|
||||||
import { ChatPanel } from './components/ChatPanel';
|
import { ChatPanel } from './components/ChatPanel';
|
||||||
@@ -10,13 +13,8 @@ import { LoginPage } from './components/LoginPage';
|
|||||||
import { Menu, Loader2 } from 'lucide-react';
|
import { Menu, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const {
|
const { sessions, createSession } = useSessions();
|
||||||
sessions,
|
const { tabOrder, splitSessions, focusedSessionId } = useUI();
|
||||||
tabOrder,
|
|
||||||
splitSessions,
|
|
||||||
focusedSessionId,
|
|
||||||
createSession,
|
|
||||||
} = useSessionManager();
|
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
@@ -130,9 +128,15 @@ function AuthenticatedApp() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HostProvider>
|
<HostProvider>
|
||||||
<SessionProvider>
|
<SettingsProvider>
|
||||||
<AppContent />
|
<UIProvider>
|
||||||
</SessionProvider>
|
<MessagesProvider>
|
||||||
|
<SessionsProvider>
|
||||||
|
<AppContent />
|
||||||
|
</SessionsProvider>
|
||||||
|
</MessagesProvider>
|
||||||
|
</UIProvider>
|
||||||
|
</SettingsProvider>
|
||||||
</HostProvider>
|
</HostProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { ChatInput } from './ChatInput';
|
|||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { PermissionDialog } from './PermissionDialog';
|
import { PermissionDialog } from './PermissionDialog';
|
||||||
import { HelpDialog } from './HelpDialog';
|
import { HelpDialog } from './HelpDialog';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessions } from '../contexts/SessionsContext';
|
||||||
|
import { useMessages } from '../contexts/MessagesContext';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
// 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) {
|
function useMemoizedSession(sessionId) {
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
sessionMessages,
|
|
||||||
startClaudeSession,
|
startClaudeSession,
|
||||||
stopClaudeSession,
|
stopClaudeSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
clearMessages,
|
|
||||||
setCompacting,
|
setCompacting,
|
||||||
changePermissionMode,
|
changePermissionMode,
|
||||||
respondToPermission,
|
respondToPermission,
|
||||||
} = useSessionManager();
|
} = useSessions();
|
||||||
|
|
||||||
|
const { sessionMessages, clearMessages } = useMessages();
|
||||||
|
|
||||||
const session = sessions[sessionId];
|
const session = sessions[sessionId];
|
||||||
const messages = sessionMessages[sessionId] || [];
|
const messages = sessionMessages[sessionId] || [];
|
||||||
|
|||||||
@@ -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 TextareaAutosize from 'react-textarea-autosize';
|
||||||
import {
|
import {
|
||||||
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
||||||
@@ -177,7 +178,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
|||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
const hostConfig = hosts[hostId] || null;
|
const hostConfig = hosts[hostId] || null;
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const messagesEndRef = useRef(null);
|
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
const prevMessageCount = useRef(messages.length);
|
const prevMessageCount = useRef(messages.length);
|
||||||
@@ -203,30 +203,6 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
|||||||
}
|
}
|
||||||
}, [checkIfAtBottom]);
|
}, [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
|
// Cache for incremental updates - avoid full rebuild on streaming content changes
|
||||||
const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() });
|
const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() });
|
||||||
|
|
||||||
@@ -295,6 +271,82 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
|||||||
return result;
|
return result;
|
||||||
}, [messages]);
|
}, [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) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
|
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
|
||||||
@@ -310,36 +362,67 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="h-full overflow-y-auto p-4 space-y-4"
|
className="h-full overflow-y-auto"
|
||||||
>
|
>
|
||||||
{processedMessages.map((message, index) => (
|
{/* Virtual list container */}
|
||||||
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}-${hostConfig?.avatar || 'no-avatar'}`} message={message} onSendMessage={onSendMessage} hostConfig={hostConfig} />
|
<div
|
||||||
))}
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((virtualItem) => {
|
||||||
|
const isProcessingIndicator = virtualItem.index === processedMessages.length;
|
||||||
|
|
||||||
{/* Processing indicator */}
|
return (
|
||||||
{isProcessing && (
|
<div
|
||||||
<div className="flex items-center gap-3 text-dark-400">
|
key={virtualItem.key}
|
||||||
<Bot className="w-6 h-6" />
|
data-index={virtualItem.index}
|
||||||
<div className="flex gap-1">
|
ref={virtualizer.measureElement}
|
||||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
style={{
|
||||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
position: 'absolute',
|
||||||
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
top: 0,
|
||||||
</div>
|
left: 0,
|
||||||
</div>
|
width: '100%',
|
||||||
)}
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
<div ref={messagesEndRef} />
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
{isProcessingIndicator ? (
|
||||||
|
// Processing indicator
|
||||||
|
<div className="flex items-center gap-3 text-dark-400">
|
||||||
|
<Bot className="w-6 h-6" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||||
|
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||||
|
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Message
|
||||||
|
message={processedMessages[virtualItem.index]}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
hostConfig={hostConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating scroll-to-bottom button */}
|
{/* Floating scroll-to-bottom button */}
|
||||||
{showScrollButton && (
|
{showScrollButton && (
|
||||||
<button
|
<button
|
||||||
onClick={scrollToBottom}
|
onClick={scrollToBottomVirtual}
|
||||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-4 h-4" />
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2, LogOut, User, Shield } from 'lucide-react';
|
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2, LogOut, User, Shield } from 'lucide-react';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessions } from '../contexts/SessionsContext';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext';
|
||||||
|
import { useUI } from '../contexts/UIContext';
|
||||||
|
import { useMessages } from '../contexts/MessagesContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||||
@@ -38,18 +41,20 @@ function addRecentDir(hostId, path) {
|
|||||||
|
|
||||||
export function Sidebar({ open, onToggle }) {
|
export function Sidebar({ open, onToggle }) {
|
||||||
const {
|
const {
|
||||||
focusedSessionId,
|
sessions,
|
||||||
focusedSession,
|
|
||||||
startClaudeSession,
|
startClaudeSession,
|
||||||
stopClaudeSession,
|
stopClaudeSession,
|
||||||
clearMessages,
|
|
||||||
updateSessionConfig,
|
updateSessionConfig,
|
||||||
settings,
|
} = useSessions();
|
||||||
updateSettings,
|
|
||||||
} = useSessionManager();
|
|
||||||
|
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { focusedSessionId } = useUI();
|
||||||
|
const { clearMessages } = useMessages();
|
||||||
const { user, authEnabled, logout, isAdmin } = useAuth();
|
const { user, authEnabled, logout, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
// Get focused session from sessions map
|
||||||
|
const focusedSession = focusedSessionId ? sessions[focusedSessionId] : null;
|
||||||
|
|
||||||
const [hosts, setHosts] = useState([]);
|
const [hosts, setHosts] = useState([]);
|
||||||
const [recentDirs, setRecentDirs] = useState([]);
|
const [recentDirs, setRecentDirs] = useState([]);
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useState, useRef, useCallback, useEffect } from 'react';
|
import { memo, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { X, Maximize2, GripHorizontal, GripVertical } from 'lucide-react';
|
import { X, Maximize2, GripHorizontal, GripVertical } from 'lucide-react';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessions } from '../contexts/SessionsContext';
|
||||||
|
import { useUI } from '../contexts/UIContext';
|
||||||
import { useHosts } from '../contexts/HostContext';
|
import { useHosts } from '../contexts/HostContext';
|
||||||
|
|
||||||
// Resizable divider - uses DOM manipulation during drag for smooth resize
|
// Resizable divider - uses DOM manipulation during drag for smooth resize
|
||||||
@@ -183,7 +184,8 @@ function getDisplayName(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SplitLayout({ splitSessions, renderPanel }) {
|
export function SplitLayout({ splitSessions, renderPanel }) {
|
||||||
const { sessions, removeFromSplit, clearSplit, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
const { sessions } = useSessions();
|
||||||
|
const { removeFromSplit, clearSplit, setFocusedSessionId, focusedSessionId } = useUI();
|
||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useCallback, memo, useEffect } from 'react';
|
import { useState, useRef, useCallback, memo, useEffect } from 'react';
|
||||||
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle, Server, ChevronDown } from 'lucide-react';
|
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle, Server, ChevronDown } from 'lucide-react';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessions } from '../contexts/SessionsContext';
|
||||||
|
import { useUI } from '../contexts/UIContext';
|
||||||
import { useHosts } from '../contexts/HostContext';
|
import { useHosts } from '../contexts/HostContext';
|
||||||
|
|
||||||
// Custom comparison for Tab memo - only re-render when these specific fields change
|
// Custom comparison for Tab memo - only re-render when these specific fields change
|
||||||
@@ -163,19 +164,22 @@ const Tab = memo(function Tab({
|
|||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
tabOrder,
|
|
||||||
splitSessions,
|
|
||||||
focusedSessionId,
|
|
||||||
createSession,
|
createSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
setFocusedSessionId,
|
|
||||||
markAsRead,
|
markAsRead,
|
||||||
|
} = useSessions();
|
||||||
|
|
||||||
|
const {
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
setFocusedSessionId,
|
||||||
reorderTabs,
|
reorderTabs,
|
||||||
addToSplit,
|
addToSplit,
|
||||||
removeFromSplit,
|
removeFromSplit,
|
||||||
clearSplit,
|
clearSplit,
|
||||||
} = useSessionManager();
|
} = useUI();
|
||||||
|
|
||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
const [dragIndex, setDragIndex] = useState(null);
|
const [dragIndex, setDragIndex] = useState(null);
|
||||||
|
|||||||
132
frontend/src/contexts/MessagesContext.jsx
Normal file
132
frontend/src/contexts/MessagesContext.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
|
||||||
|
const MessagesContext = createContext(null);
|
||||||
|
|
||||||
|
export function MessagesProvider({ children }) {
|
||||||
|
// Messages stored separately per session for performance
|
||||||
|
const [sessionMessages, setSessionMessages] = useState({});
|
||||||
|
|
||||||
|
// Throttled streaming update refs
|
||||||
|
const pendingStreamUpdates = useRef({});
|
||||||
|
const streamUpdateTimers = useRef({});
|
||||||
|
|
||||||
|
// Current assistant message refs keyed by session ID (for streaming)
|
||||||
|
const currentAssistantMessages = useRef({});
|
||||||
|
|
||||||
|
// Add message to session
|
||||||
|
const addMessage = useCallback((sessionId, message) => {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: [...(prev[sessionId] || []), { ...message, timestamp: Date.now() }],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update last assistant message (for streaming) - throttled to reduce re-renders
|
||||||
|
const updateLastAssistantMessage = useCallback((sessionId, content) => {
|
||||||
|
// Store the latest content
|
||||||
|
pendingStreamUpdates.current[sessionId] = content;
|
||||||
|
|
||||||
|
// If no timer is running, start one
|
||||||
|
if (!streamUpdateTimers.current[sessionId]) {
|
||||||
|
streamUpdateTimers.current[sessionId] = setTimeout(() => {
|
||||||
|
const latestContent = pendingStreamUpdates.current[sessionId];
|
||||||
|
delete pendingStreamUpdates.current[sessionId];
|
||||||
|
delete streamUpdateTimers.current[sessionId];
|
||||||
|
|
||||||
|
setSessionMessages(prev => {
|
||||||
|
const messages = [...(prev[sessionId] || [])];
|
||||||
|
|
||||||
|
// Find last assistant message
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'assistant') {
|
||||||
|
messages[i] = { ...messages[i], content: latestContent };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[sessionId]: messages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, 50); // Update UI max every 50ms
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear messages for a session
|
||||||
|
const clearMessages = useCallback((sessionId) => {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: [],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set messages directly (for history loading)
|
||||||
|
const setMessages = useCallback((sessionId, messages) => {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: messages,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove messages for a session (cleanup)
|
||||||
|
const removeSessionMessages = useCallback((sessionId) => {
|
||||||
|
setSessionMessages(prev => {
|
||||||
|
const { [sessionId]: removed, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expose currentAssistantMessages ref for WebSocket handler
|
||||||
|
const getCurrentAssistantMessage = useCallback((sessionId) => {
|
||||||
|
return currentAssistantMessages.current[sessionId];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCurrentAssistantMessage = useCallback((sessionId, content) => {
|
||||||
|
if (content === undefined) {
|
||||||
|
delete currentAssistantMessages.current[sessionId];
|
||||||
|
} else {
|
||||||
|
currentAssistantMessages.current[sessionId] = content;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
sessionMessages,
|
||||||
|
addMessage,
|
||||||
|
updateLastAssistantMessage,
|
||||||
|
clearMessages,
|
||||||
|
setMessages,
|
||||||
|
removeSessionMessages,
|
||||||
|
getCurrentAssistantMessage,
|
||||||
|
setCurrentAssistantMessage,
|
||||||
|
}), [
|
||||||
|
sessionMessages,
|
||||||
|
addMessage,
|
||||||
|
updateLastAssistantMessage,
|
||||||
|
clearMessages,
|
||||||
|
setMessages,
|
||||||
|
removeSessionMessages,
|
||||||
|
getCurrentAssistantMessage,
|
||||||
|
setCurrentAssistantMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessagesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MessagesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages() {
|
||||||
|
const context = useContext(MessagesContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMessages must be used within MessagesProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get messages for a specific session only
|
||||||
|
export function useSessionMessages(sessionId) {
|
||||||
|
const { sessionMessages } = useMessages();
|
||||||
|
return sessionMessages[sessionId] || [];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
55
frontend/src/contexts/SettingsContext.jsx
Normal file
55
frontend/src/contexts/SettingsContext.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const SETTINGS_STORAGE_KEY = 'claude-webui-settings';
|
||||||
|
|
||||||
|
// Load global settings from localStorage
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : { autoConnect: true, autoStart: false };
|
||||||
|
} catch {
|
||||||
|
return { autoConnect: true, autoStart: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save global settings to localStorage
|
||||||
|
function saveSettings(settings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsContext = createContext(null);
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }) {
|
||||||
|
const [settings, setSettings] = useState(() => loadSettings());
|
||||||
|
|
||||||
|
const updateSettings = useCallback((newSettings) => {
|
||||||
|
setSettings(prev => {
|
||||||
|
const updated = { ...prev, ...newSettings };
|
||||||
|
saveSettings(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
}), [settings, updateSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSettings must be used within SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
148
frontend/src/contexts/UIContext.jsx
Normal file
148
frontend/src/contexts/UIContext.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
||||||
|
|
||||||
|
const UIContext = createContext(null);
|
||||||
|
|
||||||
|
export function UIProvider({ children }) {
|
||||||
|
// Tab order (array of session IDs)
|
||||||
|
const [tabOrder, setTabOrder] = useState([]);
|
||||||
|
|
||||||
|
// Sessions shown in split view (array of session IDs)
|
||||||
|
const [splitSessions, setSplitSessions] = useState([]);
|
||||||
|
|
||||||
|
// Currently focused session ID
|
||||||
|
const [focusedSessionId, setFocusedSessionId] = useState(null);
|
||||||
|
|
||||||
|
// Load UI state from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
setTabOrder(data.tabOrder || []);
|
||||||
|
setSplitSessions(data.splitSessions || []);
|
||||||
|
setFocusedSessionId(data.focusedSessionId || null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to restore UI state:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save UI state to localStorage on change (debounced)
|
||||||
|
const saveTimeoutRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Read existing storage and merge UI state
|
||||||
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
||||||
|
const data = stored ? JSON.parse(stored) : {};
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...data,
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save UI state:', e);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [tabOrder, splitSessions, focusedSessionId]);
|
||||||
|
|
||||||
|
// Reorder tabs
|
||||||
|
const reorderTabs = useCallback((fromIndex, toIndex) => {
|
||||||
|
setTabOrder(prev => {
|
||||||
|
const result = [...prev];
|
||||||
|
const [removed] = result.splice(fromIndex, 1);
|
||||||
|
result.splice(toIndex, 0, removed);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add session to tab order
|
||||||
|
const addTab = useCallback((sessionId) => {
|
||||||
|
setTabOrder(prev => [...prev, sessionId]);
|
||||||
|
setFocusedSessionId(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove session from tab order
|
||||||
|
const removeTab = useCallback((sessionId) => {
|
||||||
|
setTabOrder(prev => {
|
||||||
|
const filtered = prev.filter(id => id !== sessionId);
|
||||||
|
// Update focus if needed
|
||||||
|
if (focusedSessionId === sessionId) {
|
||||||
|
setFocusedSessionId(filtered[0] || null);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
||||||
|
}, [focusedSessionId]);
|
||||||
|
|
||||||
|
// Add session to split view
|
||||||
|
const addToSplit = useCallback((sessionId) => {
|
||||||
|
setSplitSessions(prev => {
|
||||||
|
if (prev.includes(sessionId)) return prev;
|
||||||
|
if (prev.length >= 4) return prev; // Max 4 panels
|
||||||
|
return [...prev, sessionId];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove session from split view
|
||||||
|
const removeFromSplit = useCallback((sessionId) => {
|
||||||
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear split view
|
||||||
|
const clearSplit = useCallback(() => {
|
||||||
|
setSplitSessions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
setFocusedSessionId,
|
||||||
|
reorderTabs,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
addToSplit,
|
||||||
|
removeFromSplit,
|
||||||
|
clearSplit,
|
||||||
|
}), [
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
reorderTabs,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
addToSplit,
|
||||||
|
removeFromSplit,
|
||||||
|
clearSplit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UIContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UIContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUI() {
|
||||||
|
const context = useContext(UIContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUI must be used within UIProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user