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:
2025-12-20 17:28:03 +01:00
parent fbc8103034
commit e5d17bfad3
17 changed files with 827 additions and 564 deletions

5
frontend/.npmrc Normal file
View File

@@ -0,0 +1,5 @@
fetch-timeout=300000
fetch-retries=3
fetch-retry-factor=2
fetch-retry-mintimeout=10000
fetch-retry-maxtimeout=60000

View File

@@ -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;"]

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<HostProvider>
<SessionProvider>
<AppContent />
</SessionProvider>
<SettingsProvider>
<UIProvider>
<MessagesProvider>
<SessionsProvider>
<AppContent />
</SessionsProvider>
</MessagesProvider>
</UIProvider>
</SettingsProvider>
</HostProvider>
);
}

View File

@@ -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] || [];

View File

@@ -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 (
<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 (
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-4 space-y-4"
className="h-full overflow-y-auto"
>
{processedMessages.map((message, index) => (
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}-${hostConfig?.avatar || 'no-avatar'}`} message={message} onSendMessage={onSendMessage} hostConfig={hostConfig} />
))}
{/* Virtual list container */}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{items.map((virtualItem) => {
const isProcessingIndicator = virtualItem.index === processedMessages.length;
{/* Processing indicator */}
{isProcessing && (
<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>
)}
<div ref={messagesEndRef} />
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
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>
{/* Floating scroll-to-bottom button */}
{showScrollButton && (
<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"
>
<ArrowDown className="w-4 h-4" />

View File

@@ -1,6 +1,9 @@
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 { 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';
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
@@ -38,18 +41,20 @@ function addRecentDir(hostId, path) {
export function Sidebar({ open, onToggle }) {
const {
focusedSessionId,
focusedSession,
sessions,
startClaudeSession,
stopClaudeSession,
clearMessages,
updateSessionConfig,
settings,
updateSettings,
} = useSessionManager();
} = useSessions();
const { settings, updateSettings } = useSettings();
const { focusedSessionId } = useUI();
const { clearMessages } = useMessages();
const { user, authEnabled, logout, isAdmin } = useAuth();
// Get focused session from sessions map
const focusedSession = focusedSessionId ? sessions[focusedSessionId] : null;
const [hosts, setHosts] = useState([]);
const [recentDirs, setRecentDirs] = useState([]);
const [showBrowser, setShowBrowser] = useState(false);

View File

@@ -1,6 +1,7 @@
import { memo, useState, useRef, useCallback, useEffect } from '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';
// Resizable divider - uses DOM manipulation during drag for smooth resize
@@ -183,7 +184,8 @@ function getDisplayName(session) {
}
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 [sizes, setSizes] = useState({ h: 50, v: 50 });

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, memo, useEffect } from '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';
// 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() {
const {
sessions,
tabOrder,
splitSessions,
focusedSessionId,
createSession,
removeSession,
renameSession,
setFocusedSessionId,
markAsRead,
} = useSessions();
const {
tabOrder,
splitSessions,
focusedSessionId,
setFocusedSessionId,
reorderTabs,
addToSplit,
removeFromSplit,
clearSplit,
} = useSessionManager();
} = useUI();
const { hosts } = useHosts();
const [dragIndex, setDragIndex] = useState(null);

View 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] || [];
}

View 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;
}

View 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;
}