perf: Add performance optimizations and Tanuki avatar
Frontend: - Add memoization to MessageList with custom comparison function - Implement incremental caching for processedMessages to avoid O(n) rebuilds during streaming - Wrap Message component with memo() - Add better error handling for file uploads in SessionContext Backend: - Improve upload error handling with proper response checks Infrastructure: - Add client_max_body_size 100m to nginx for file uploads - Add Tanuki avatar (optimized 256x256, 77KB) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -175,7 +175,9 @@ app.get('/api/hosts', requireAuth, (req, res) => {
|
|||||||
color: host.color,
|
color: host.color,
|
||||||
icon: host.icon,
|
icon: host.icon,
|
||||||
connectionType: host.connection.type,
|
connectionType: host.connection.type,
|
||||||
isLocal: host.connection.type === 'local'
|
isLocal: host.connection.type === 'local',
|
||||||
|
basePaths: host.basePaths || [],
|
||||||
|
defaultPath: host.basePaths?.[0] || '/home'
|
||||||
}));
|
}));
|
||||||
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
# Always HTTPS since NPM handles SSL termination (required for secure cookies)
|
# Always HTTPS since NPM handles SSL termination (required for secure cookies)
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
# Allow file uploads up to 100MB
|
||||||
|
client_max_body_size 100m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Auth routes proxy
|
# Auth routes proxy
|
||||||
|
|||||||
BIN
frontend/public/tanuki-avatar.png
Normal file
BIN
frontend/public/tanuki-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -119,8 +119,28 @@ function SystemHints({ reminders, inline = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not using memo here - needs to re-render when HostContext updates
|
// Custom comparison for MessageList - only re-render when these specific things change
|
||||||
export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
const messageListPropsAreEqual = (prev, next) => {
|
||||||
|
// Check if messages array length changed
|
||||||
|
if (prev.messages.length !== next.messages.length) return false;
|
||||||
|
|
||||||
|
// Check if last message content changed (for streaming)
|
||||||
|
if (prev.messages.length > 0 && next.messages.length > 0) {
|
||||||
|
const prevLast = prev.messages[prev.messages.length - 1];
|
||||||
|
const nextLast = next.messages[next.messages.length - 1];
|
||||||
|
if (prevLast.content !== nextLast.content) return false;
|
||||||
|
if (prevLast.type !== nextLast.type) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check other props
|
||||||
|
return (
|
||||||
|
prev.isProcessing === next.isProcessing &&
|
||||||
|
prev.onSendMessage === next.onSendMessage &&
|
||||||
|
prev.hostId === next.hostId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageList = memo(function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
const hostConfig = hosts[hostId] || null;
|
const hostConfig = hosts[hostId] || null;
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -174,8 +194,35 @@ export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
|||||||
setNewMessageCount(0);
|
setNewMessageCount(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cache for incremental updates - avoid full rebuild on streaming content changes
|
||||||
|
const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() });
|
||||||
|
|
||||||
// Preprocess messages to pair tool_use with tool_result
|
// Preprocess messages to pair tool_use with tool_result
|
||||||
|
// Optimized: only rebuild when structure changes, not content
|
||||||
const processedMessages = useMemo(() => {
|
const processedMessages = useMemo(() => {
|
||||||
|
const cache = processedCacheRef.current;
|
||||||
|
|
||||||
|
// Fast path: if only last message content changed (streaming), return cached result
|
||||||
|
if (cache.messages.length === messages.length && messages.length > 0) {
|
||||||
|
const lastCached = cache.messages[cache.messages.length - 1];
|
||||||
|
const lastCurrent = messages[messages.length - 1];
|
||||||
|
|
||||||
|
// Check if structure is same (only content might have changed)
|
||||||
|
if (lastCached.type === lastCurrent.type &&
|
||||||
|
lastCached.timestamp === lastCurrent.timestamp &&
|
||||||
|
lastCached.toolUseId === lastCurrent.toolUseId) {
|
||||||
|
// Content update only - update the cached result's last item content
|
||||||
|
if (cache.result.length > 0 && lastCurrent.type === 'assistant') {
|
||||||
|
cache.result[cache.result.length - 1] = {
|
||||||
|
...cache.result[cache.result.length - 1],
|
||||||
|
content: lastCurrent.content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return cache.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full rebuild needed
|
||||||
const result = [];
|
const result = [];
|
||||||
const toolResultMap = new Map();
|
const toolResultMap = new Map();
|
||||||
|
|
||||||
@@ -207,6 +254,11 @@ export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cache.messages = messages;
|
||||||
|
cache.result = result;
|
||||||
|
cache.toolResultMap = toolResultMap;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@@ -265,9 +317,10 @@ export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}, messageListPropsAreEqual);
|
||||||
|
|
||||||
function Message({ message, onSendMessage, hostConfig }) {
|
// Memoize Message component to prevent re-renders during streaming
|
||||||
|
const Message = memo(function Message({ message, onSendMessage, hostConfig }) {
|
||||||
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -434,7 +487,7 @@ function Message({ message, onSendMessage, hostConfig }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <div>{renderContent()}</div>;
|
return <div>{renderContent()}</div>;
|
||||||
}
|
});
|
||||||
|
|
||||||
// Tool configuration with icons, colors, and display logic
|
// Tool configuration with icons, colors, and display logic
|
||||||
const TOOL_CONFIG = {
|
const TOOL_CONFIG = {
|
||||||
|
|||||||
@@ -92,12 +92,6 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
setShowBrowser(false);
|
setShowBrowser(false);
|
||||||
}, [focusedSessionId, currentHost, updateSessionConfig]);
|
}, [focusedSessionId, currentHost, updateSessionConfig]);
|
||||||
|
|
||||||
// Handle host change
|
|
||||||
const handleSelectHost = useCallback((hostId) => {
|
|
||||||
if (!focusedSessionId || sessionActive) return;
|
|
||||||
updateSessionConfig(focusedSessionId, { host: hostId });
|
|
||||||
}, [focusedSessionId, sessionActive, updateSessionConfig]);
|
|
||||||
|
|
||||||
// Handle resume toggle
|
// Handle resume toggle
|
||||||
const handleToggleResume = useCallback(() => {
|
const handleToggleResume = useCallback(() => {
|
||||||
if (!focusedSessionId) return;
|
if (!focusedSessionId) return;
|
||||||
@@ -221,47 +215,34 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
{/* Host Selection */}
|
{/* Host Display (read-only - host is fixed per session) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||||
Host
|
Host
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
{(() => {
|
||||||
{hosts.map((host) => {
|
const currentHostConfig = hosts.find(h => h.id === currentHost);
|
||||||
const isSelected = currentHost === host.id;
|
return currentHostConfig ? (
|
||||||
const isDisabled = sessionActive && currentHost !== host.id;
|
<div
|
||||||
return (
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm border border-dark-700"
|
||||||
<button
|
style={{ backgroundColor: `${currentHostConfig.color}20` }}
|
||||||
key={host.id}
|
|
||||||
onClick={() => !isDisabled && handleSelectHost(host.id)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
|
||||||
transition-colors border relative
|
|
||||||
${isDisabled
|
|
||||||
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
|
|
||||||
: isSelected
|
|
||||||
? 'border-orange-500/50 text-white'
|
|
||||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: isSelected ? `${host.color}30` : 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
<Server className="w-4 h-4" style={{ color: currentHostConfig.color }} />
|
||||||
<span>{host.name}</span>
|
<div className="flex-1">
|
||||||
{sessionActive && isSelected && (
|
<span className="text-white font-medium">{currentHostConfig.name}</span>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{sessionActive && (
|
{sessionActive && (
|
||||||
<p className="text-xs text-dark-500">Stop session to switch hosts</p>
|
<span className="ml-2 w-2 h-2 inline-block rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-dark-500 text-sm">Loading...</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<p className="text-xs text-dark-500">
|
||||||
|
Host is fixed per session. Create a new tab for a different host.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Working Directory */}
|
{/* Working Directory */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -446,6 +427,21 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Clear Messages
|
Clear Messages
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Reset all sessions? This will clear all tabs and messages.')) {
|
||||||
|
localStorage.removeItem('claude-webui-sessions');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
||||||
|
bg-dark-800 hover:bg-red-900/50 rounded-lg
|
||||||
|
text-dark-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Reset All Sessions
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,9 +488,10 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 text-xs text-dark-500">
|
<div className="p-4 border-t border-dark-800">
|
||||||
<div>Claude Code Web UI</div>
|
<div className="text-xs text-dark-600 text-center">
|
||||||
<div>Multi-Session Mode</div>
|
Claude Code Web UI
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
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
|
||||||
const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
||||||
@@ -96,49 +97,50 @@ const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Panel wrapper with header
|
// Custom comparison for PanelWrapper - only re-render when specific fields change
|
||||||
const PanelWrapper = memo(function PanelWrapper({ sessionId, children, onRemove, onMaximize }) {
|
const panelWrapperPropsAreEqual = (prev, next) => {
|
||||||
const { sessions, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
return (
|
||||||
const session = sessions[sessionId];
|
prev.sessionId === next.sessionId &&
|
||||||
const isFocused = focusedSessionId === sessionId;
|
prev.isFocused === next.isFocused &&
|
||||||
|
prev.hostColor === next.hostColor &&
|
||||||
if (!session) return null;
|
prev.displayName === next.displayName &&
|
||||||
|
prev.isActive === next.isActive &&
|
||||||
// Get host color
|
prev.isProcessing === next.isProcessing &&
|
||||||
const getHostColor = () => {
|
prev.children === next.children
|
||||||
switch (session.host) {
|
);
|
||||||
case 'neko': return '#f97316';
|
|
||||||
case 'mochi': return '#22c55e';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get display name
|
|
||||||
const getDisplayName = () => {
|
|
||||||
if (session.name) return session.name;
|
|
||||||
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
|
||||||
const context = session.currentContext || session.project.split('/').pop() || 'New';
|
|
||||||
return `${hostName}: ${context}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Panel wrapper with header - receives pre-computed props to avoid context re-renders
|
||||||
|
const PanelWrapper = memo(function PanelWrapper({
|
||||||
|
sessionId,
|
||||||
|
children,
|
||||||
|
onRemove,
|
||||||
|
onMaximize,
|
||||||
|
onFocus,
|
||||||
|
isFocused,
|
||||||
|
hostColor,
|
||||||
|
displayName,
|
||||||
|
isActive,
|
||||||
|
isProcessing,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex flex-col h-full overflow-hidden rounded-lg border
|
flex flex-col h-full overflow-hidden rounded-lg border
|
||||||
${isFocused ? 'border-orange-500/50' : 'border-dark-700'}
|
${isFocused ? 'border-orange-500/50' : 'border-dark-700'}
|
||||||
`}
|
`}
|
||||||
onClick={() => setFocusedSessionId(sessionId)}
|
onClick={() => onFocus(sessionId)}
|
||||||
>
|
>
|
||||||
{/* Mini header */}
|
{/* Mini header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2 py-1 bg-dark-800/80 border-b border-dark-700 flex-shrink-0"
|
className="flex items-center gap-2 px-2 py-1 bg-dark-800/80 border-b border-dark-700 flex-shrink-0"
|
||||||
style={{ borderLeftColor: getHostColor(), borderLeftWidth: '3px' }}
|
style={{ borderLeftColor: hostColor, borderLeftWidth: '3px' }}
|
||||||
>
|
>
|
||||||
<span className={`w-2 h-2 rounded-full ${session.active ? 'bg-green-500' : 'bg-dark-500'}`} />
|
<span className={`w-2 h-2 rounded-full ${isActive ? 'bg-green-500' : 'bg-dark-500'}`} />
|
||||||
<span className="flex-1 text-xs font-medium text-dark-300 truncate">
|
<span className="flex-1 text-xs font-medium text-dark-300 truncate">
|
||||||
{getDisplayName()}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
{session.isProcessing && (
|
{isProcessing && (
|
||||||
<span className="text-[10px] text-orange-400 animate-pulse">Processing...</span>
|
<span className="text-[10px] text-orange-400 animate-pulse">Processing...</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -169,10 +171,20 @@ const PanelWrapper = memo(function PanelWrapper({ sessionId, children, onRemove,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}, panelWrapperPropsAreEqual);
|
||||||
|
|
||||||
|
// Helper to compute display name
|
||||||
|
function getDisplayName(session) {
|
||||||
|
if (!session) return 'Unknown';
|
||||||
|
if (session.name) return session.name;
|
||||||
|
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||||
|
const context = session.currentContext || session.project.split('/').pop() || 'New';
|
||||||
|
return `${hostName}: ${context}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function SplitLayout({ splitSessions, renderPanel }) {
|
export function SplitLayout({ splitSessions, renderPanel }) {
|
||||||
const { removeFromSplit, clearSplit, setFocusedSessionId } = useSessionManager();
|
const { sessions, removeFromSplit, clearSplit, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
||||||
|
const { hosts } = useHosts();
|
||||||
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
||||||
|
|
||||||
// Refs for direct DOM manipulation during resize
|
// Refs for direct DOM manipulation during resize
|
||||||
@@ -188,6 +200,10 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
setFocusedSessionId(sessionId);
|
setFocusedSessionId(sessionId);
|
||||||
}, [clearSplit, setFocusedSessionId]);
|
}, [clearSplit, setFocusedSessionId]);
|
||||||
|
|
||||||
|
const handleFocus = useCallback((sessionId) => {
|
||||||
|
setFocusedSessionId(sessionId);
|
||||||
|
}, [setFocusedSessionId]);
|
||||||
|
|
||||||
const handleHorizontalResizeEnd = useCallback((percentage) => {
|
const handleHorizontalResizeEnd = useCallback((percentage) => {
|
||||||
setSizes(prev => ({ ...prev, h: percentage }));
|
setSizes(prev => ({ ...prev, h: percentage }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -196,19 +212,34 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
setSizes(prev => ({ ...prev, v: percentage }));
|
setSizes(prev => ({ ...prev, v: percentage }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Helper to get panel props for a session
|
||||||
|
const getPanelProps = useCallback((sessionId) => {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return null;
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
onRemove: handleRemove,
|
||||||
|
onMaximize: handleMaximize,
|
||||||
|
onFocus: handleFocus,
|
||||||
|
isFocused: focusedSessionId === sessionId,
|
||||||
|
hostColor: hosts[session.host]?.color || '#6b7280',
|
||||||
|
displayName: getDisplayName(session),
|
||||||
|
isActive: session.active,
|
||||||
|
isProcessing: session.isProcessing,
|
||||||
|
};
|
||||||
|
}, [sessions, hosts, focusedSessionId, handleRemove, handleMaximize, handleFocus]);
|
||||||
|
|
||||||
const count = splitSessions.length;
|
const count = splitSessions.length;
|
||||||
|
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
|
|
||||||
// Single panel (shouldn't happen in split mode, but handle it)
|
// Single panel (shouldn't happen in split mode, but handle it)
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
|
const props = getPanelProps(splitSessions[0]);
|
||||||
|
if (!props) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-full p-1">
|
<div className="h-full p-1">
|
||||||
<PanelWrapper
|
<PanelWrapper {...props}>
|
||||||
sessionId={splitSessions[0]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[0])}
|
{renderPanel(splitSessions[0])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,6 +248,9 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
|
|
||||||
// Two panels - horizontal split
|
// Two panels - horizontal split
|
||||||
if (count === 2) {
|
if (count === 2) {
|
||||||
|
const props0 = getPanelProps(splitSessions[0]);
|
||||||
|
const props1 = getPanelProps(splitSessions[1]);
|
||||||
|
if (!props0 || !props1) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex p-1 gap-0">
|
<div className="h-full flex p-1 gap-0">
|
||||||
<div
|
<div
|
||||||
@@ -224,11 +258,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
style={{ width: `${sizes.h}%` }}
|
style={{ width: `${sizes.h}%` }}
|
||||||
className="min-w-0 h-full"
|
className="min-w-0 h-full"
|
||||||
>
|
>
|
||||||
<PanelWrapper
|
<PanelWrapper {...props0}>
|
||||||
sessionId={splitSessions[0]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[0])}
|
{renderPanel(splitSessions[0])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,11 +268,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
onResizeEnd={handleHorizontalResizeEnd}
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
<PanelWrapper
|
<PanelWrapper {...props1}>
|
||||||
sessionId={splitSessions[1]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[1])}
|
{renderPanel(splitSessions[1])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,58 +276,44 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Three panels - 2 on left, 1 on right
|
// Three panels - all horizontal (side-by-side)
|
||||||
if (count === 3) {
|
if (count === 3) {
|
||||||
|
const props0 = getPanelProps(splitSessions[0]);
|
||||||
|
const props1 = getPanelProps(splitSessions[1]);
|
||||||
|
const props2 = getPanelProps(splitSessions[2]);
|
||||||
|
if (!props0 || !props1 || !props2) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex p-1 gap-0">
|
<div className="h-full flex p-1 gap-0">
|
||||||
{/* Left column - 2 panels stacked */}
|
|
||||||
<div
|
<div
|
||||||
ref={leftPanelRef}
|
ref={leftPanelRef}
|
||||||
style={{ width: `${sizes.h}%` }}
|
style={{ width: '33.33%' }}
|
||||||
className="flex flex-col min-w-0"
|
className="min-w-0 h-full"
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={topPanelRef}
|
|
||||||
style={{ height: `${sizes.v}%` }}
|
|
||||||
className="min-h-0"
|
|
||||||
>
|
|
||||||
<PanelWrapper
|
|
||||||
sessionId={splitSessions[0]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
>
|
||||||
|
<PanelWrapper {...props0}>
|
||||||
{renderPanel(splitSessions[0])}
|
{renderPanel(splitSessions[0])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
<Divider
|
|
||||||
direction="vertical"
|
|
||||||
panelRef={topPanelRef}
|
|
||||||
onResizeEnd={handleVerticalResizeEnd}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }} className="min-h-0">
|
|
||||||
<PanelWrapper
|
|
||||||
sessionId={splitSessions[1]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[1])}
|
|
||||||
</PanelWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider
|
<Divider
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
panelRef={leftPanelRef}
|
panelRef={leftPanelRef}
|
||||||
onResizeEnd={handleHorizontalResizeEnd}
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
{/* Right column - 1 panel full height */}
|
ref={topPanelRef}
|
||||||
<div style={{ flex: 1 }} className="min-w-0">
|
style={{ width: '33.33%' }}
|
||||||
<PanelWrapper
|
className="min-w-0 h-full"
|
||||||
sessionId={splitSessions[2]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
>
|
||||||
|
<PanelWrapper {...props1}>
|
||||||
|
{renderPanel(splitSessions[1])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
<Divider
|
||||||
|
direction="horizontal"
|
||||||
|
panelRef={topPanelRef}
|
||||||
|
onResizeEnd={handleVerticalResizeEnd}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
|
<PanelWrapper {...props2}>
|
||||||
{renderPanel(splitSessions[2])}
|
{renderPanel(splitSessions[2])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,6 +323,11 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
|
|
||||||
// Four panels - 2x2 grid
|
// Four panels - 2x2 grid
|
||||||
if (count >= 4) {
|
if (count >= 4) {
|
||||||
|
const props0 = getPanelProps(splitSessions[0]);
|
||||||
|
const props1 = getPanelProps(splitSessions[1]);
|
||||||
|
const props2 = getPanelProps(splitSessions[2]);
|
||||||
|
const props3 = getPanelProps(splitSessions[3]);
|
||||||
|
if (!props0 || !props1 || !props2 || !props3) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-1 gap-0">
|
<div className="h-full flex flex-col p-1 gap-0">
|
||||||
{/* Top row */}
|
{/* Top row */}
|
||||||
@@ -324,11 +341,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
style={{ width: `${sizes.h}%` }}
|
style={{ width: `${sizes.h}%` }}
|
||||||
className="min-w-0 h-full"
|
className="min-w-0 h-full"
|
||||||
>
|
>
|
||||||
<PanelWrapper
|
<PanelWrapper {...props0}>
|
||||||
sessionId={splitSessions[0]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[0])}
|
{renderPanel(splitSessions[0])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +351,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
onResizeEnd={handleHorizontalResizeEnd}
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
<PanelWrapper
|
<PanelWrapper {...props1}>
|
||||||
sessionId={splitSessions[1]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[1])}
|
{renderPanel(splitSessions[1])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,11 +366,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
{/* Bottom row */}
|
{/* Bottom row */}
|
||||||
<div style={{ flex: 1 }} className="flex min-h-0">
|
<div style={{ flex: 1 }} className="flex min-h-0">
|
||||||
<div style={{ width: `${sizes.h}%` }} className="min-w-0 h-full">
|
<div style={{ width: `${sizes.h}%` }} className="min-w-0 h-full">
|
||||||
<PanelWrapper
|
<PanelWrapper {...props2}>
|
||||||
sessionId={splitSessions[2]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[2])}
|
{renderPanel(splitSessions[2])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,11 +376,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
|||||||
onResizeEnd={handleHorizontalResizeEnd}
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
<PanelWrapper
|
<PanelWrapper {...props3}>
|
||||||
sessionId={splitSessions[3]}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onMaximize={handleMaximize}
|
|
||||||
>
|
|
||||||
{renderPanel(splitSessions[3])}
|
{renderPanel(splitSessions[3])}
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useState, useRef, useCallback, memo } from 'react';
|
import { useState, useRef, useCallback, memo, useEffect } from 'react';
|
||||||
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle } from 'lucide-react';
|
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle, Server, ChevronDown } from 'lucide-react';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
import { useHosts } from '../contexts/HostContext';
|
||||||
|
|
||||||
|
// Custom comparison for Tab memo - only re-render when these specific fields change
|
||||||
|
const tabPropsAreEqual = (prev, next) => {
|
||||||
|
return (
|
||||||
|
prev.session.id === next.session.id &&
|
||||||
|
prev.session.name === next.session.name &&
|
||||||
|
prev.session.host === next.session.host &&
|
||||||
|
prev.session.project === next.session.project &&
|
||||||
|
prev.session.currentContext === next.session.currentContext &&
|
||||||
|
prev.session.active === next.session.active &&
|
||||||
|
prev.session.connected === next.session.connected &&
|
||||||
|
prev.session.isProcessing === next.session.isProcessing &&
|
||||||
|
prev.session.unreadCount === next.session.unreadCount &&
|
||||||
|
prev.isActive === next.isActive &&
|
||||||
|
prev.isSplit === next.isSplit &&
|
||||||
|
prev.index === next.index &&
|
||||||
|
prev.hostColor === next.hostColor
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Tab component
|
// Tab component
|
||||||
const Tab = memo(function Tab({
|
const Tab = memo(function Tab({
|
||||||
@@ -8,6 +28,7 @@ const Tab = memo(function Tab({
|
|||||||
isActive,
|
isActive,
|
||||||
isSplit,
|
isSplit,
|
||||||
index,
|
index,
|
||||||
|
hostColor,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -30,15 +51,6 @@ const Tab = memo(function Tab({
|
|||||||
return `${hostName}: ${context}`;
|
return `${hostName}: ${context}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get host color
|
|
||||||
const getHostColor = () => {
|
|
||||||
switch (session.host) {
|
|
||||||
case 'neko': return '#f97316';
|
|
||||||
case 'mochi': return '#22c55e';
|
|
||||||
default: return '#6b7280';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
const handleDoubleClick = () => {
|
||||||
setEditName(session.name || '');
|
setEditName(session.name || '');
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -78,7 +90,7 @@ const Tab = memo(function Tab({
|
|||||||
${isSplit ? 'ring-1 ring-inset ring-blue-500/30' : ''}
|
${isSplit ? 'ring-1 ring-inset ring-blue-500/30' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
borderTopColor: isActive ? getHostColor() : 'transparent',
|
borderTopColor: isActive ? (hostColor || '#6b7280') : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
@@ -146,7 +158,7 @@ const Tab = memo(function Tab({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}, tabPropsAreEqual);
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const {
|
const {
|
||||||
@@ -165,6 +177,7 @@ export function TabBar() {
|
|||||||
clearSplit,
|
clearSplit,
|
||||||
} = useSessionManager();
|
} = useSessionManager();
|
||||||
|
|
||||||
|
const { hosts } = useHosts();
|
||||||
const [dragIndex, setDragIndex] = useState(null);
|
const [dragIndex, setDragIndex] = useState(null);
|
||||||
|
|
||||||
const handleDragStart = useCallback((e, index) => {
|
const handleDragStart = useCallback((e, index) => {
|
||||||
@@ -202,9 +215,39 @@ export function TabBar() {
|
|||||||
}
|
}
|
||||||
}, [splitSessions, addToSplit, removeFromSplit]);
|
}, [splitSessions, addToSplit, removeFromSplit]);
|
||||||
|
|
||||||
const handleNewTab = useCallback(() => {
|
const [showNewTabMenu, setShowNewTabMenu] = useState(false);
|
||||||
createSession();
|
const newTabMenuRef = useRef(null);
|
||||||
}, [createSession]);
|
|
||||||
|
const handleNewTab = useCallback((hostId) => {
|
||||||
|
const hostConfig = hosts[hostId];
|
||||||
|
const defaultPath = hostConfig?.defaultPath || hostConfig?.basePaths?.[0] || '/home';
|
||||||
|
createSession(hostId, defaultPath);
|
||||||
|
setShowNewTabMenu(false);
|
||||||
|
}, [createSession, hosts]);
|
||||||
|
|
||||||
|
// Close menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showNewTabMenu) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
// Small delay to prevent immediate close on the same click that opened it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (newTabMenuRef.current && !newTabMenuRef.current.contains(e.target)) {
|
||||||
|
setShowNewTabMenu(false);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use click instead of mousedown, and add after a small delay
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showNewTabMenu]);
|
||||||
|
|
||||||
// Get split layout info
|
// Get split layout info
|
||||||
const getSplitInfo = () => {
|
const getSplitInfo = () => {
|
||||||
@@ -217,7 +260,7 @@ export function TabBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 px-2 py-1 bg-dark-900 border-b border-dark-800 overflow-x-auto">
|
<div className="flex items-center gap-1 px-2 py-1 bg-dark-900 border-b border-dark-800">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-end gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
|
<div className="flex items-end gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
|
||||||
{tabOrder.map((sessionId, index) => {
|
{tabOrder.map((sessionId, index) => {
|
||||||
@@ -231,6 +274,7 @@ export function TabBar() {
|
|||||||
isActive={focusedSessionId === sessionId}
|
isActive={focusedSessionId === sessionId}
|
||||||
isSplit={splitSessions.includes(sessionId)}
|
isSplit={splitSessions.includes(sessionId)}
|
||||||
index={index}
|
index={index}
|
||||||
|
hostColor={hosts[session.host]?.color}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onRename={renameSession}
|
onRename={renameSession}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -241,15 +285,33 @@ export function TabBar() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* New tab button */}
|
{/* New tab button with host dropdown - outside scrollable area */}
|
||||||
|
<div className="relative flex-shrink-0" ref={newTabMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={handleNewTab}
|
onClick={() => setShowNewTabMenu(!showNewTabMenu)}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-dark-800 text-dark-500 hover:text-dark-300 transition-colors flex-shrink-0"
|
className="flex items-center justify-center gap-1 px-2 h-8 rounded-lg hover:bg-dark-800 text-dark-500 hover:text-dark-300 transition-colors"
|
||||||
title="New session (Ctrl+T)"
|
title="New session (Ctrl+T)"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{showNewTabMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 min-w-[160px] py-1">
|
||||||
|
{Object.entries(hosts).map(([hostId, host]) => (
|
||||||
|
<button
|
||||||
|
key={hostId}
|
||||||
|
onClick={() => handleNewTab(hostId)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
|
||||||
|
<span className="text-dark-200">{host.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Split view controls */}
|
{/* Split view controls */}
|
||||||
|
|||||||
@@ -903,6 +903,13 @@ export function SessionProvider({ children }) {
|
|||||||
// Handle file uploads if needed
|
// Handle file uploads if needed
|
||||||
let uploadedFiles = [];
|
let uploadedFiles = [];
|
||||||
if (attachments.length > 0) {
|
if (attachments.length > 0) {
|
||||||
|
// Check if we have a claude session ID for uploads
|
||||||
|
if (!session.claudeSessionId) {
|
||||||
|
console.error('Upload failed: No claude session ID yet');
|
||||||
|
updateSession(sessionId, { error: 'Cannot upload files: Session not fully started yet. Please wait a moment.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of attachments) {
|
for (const file of attachments) {
|
||||||
formData.append('files', file.file);
|
formData.append('files', file.file);
|
||||||
@@ -914,6 +921,13 @@ export function SessionProvider({ children }) {
|
|||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if response is ok before parsing JSON
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
throw new Error(`Upload failed: ${res.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
uploadedFiles = data.files || [];
|
uploadedFiles = data.files || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user