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:
@@ -119,8 +119,28 @@ function SystemHints({ reminders, inline = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Not using memo here - needs to re-render when HostContext updates
|
||||
export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
||||
// Custom comparison for MessageList - only re-render when these specific things change
|
||||
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 hostConfig = hosts[hostId] || null;
|
||||
const containerRef = useRef(null);
|
||||
@@ -174,8 +194,35 @@ export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
||||
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
|
||||
// Optimized: only rebuild when structure changes, not content
|
||||
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 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;
|
||||
}, [messages]);
|
||||
|
||||
@@ -265,9 +317,10 @@ export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
|
||||
)}
|
||||
</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 { user } = useAuth();
|
||||
|
||||
@@ -434,7 +487,7 @@ function Message({ message, onSendMessage, hostConfig }) {
|
||||
};
|
||||
|
||||
return <div>{renderContent()}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
// Tool configuration with icons, colors, and display logic
|
||||
const TOOL_CONFIG = {
|
||||
|
||||
@@ -92,12 +92,6 @@ export function Sidebar({ open, onToggle }) {
|
||||
setShowBrowser(false);
|
||||
}, [focusedSessionId, currentHost, updateSessionConfig]);
|
||||
|
||||
// Handle host change
|
||||
const handleSelectHost = useCallback((hostId) => {
|
||||
if (!focusedSessionId || sessionActive) return;
|
||||
updateSessionConfig(focusedSessionId, { host: hostId });
|
||||
}, [focusedSessionId, sessionActive, updateSessionConfig]);
|
||||
|
||||
// Handle resume toggle
|
||||
const handleToggleResume = useCallback(() => {
|
||||
if (!focusedSessionId) return;
|
||||
@@ -221,46 +215,33 @@ export function Sidebar({ open, onToggle }) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||
Host
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{hosts.map((host) => {
|
||||
const isSelected = currentHost === host.id;
|
||||
const isDisabled = sessionActive && currentHost !== host.id;
|
||||
return (
|
||||
<button
|
||||
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 }} />
|
||||
<span>{host.name}</span>
|
||||
{sessionActive && isSelected && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||
{(() => {
|
||||
const currentHostConfig = hosts.find(h => h.id === currentHost);
|
||||
return currentHostConfig ? (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm border border-dark-700"
|
||||
style={{ backgroundColor: `${currentHostConfig.color}20` }}
|
||||
>
|
||||
<Server className="w-4 h-4" style={{ color: currentHostConfig.color }} />
|
||||
<div className="flex-1">
|
||||
<span className="text-white font-medium">{currentHostConfig.name}</span>
|
||||
{sessionActive && (
|
||||
<span className="ml-2 w-2 h-2 inline-block rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sessionActive && (
|
||||
<p className="text-xs text-dark-500">Stop session to switch hosts</p>
|
||||
)}
|
||||
</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 */}
|
||||
@@ -446,6 +427,21 @@ export function Sidebar({ open, onToggle }) {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear Messages
|
||||
</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>
|
||||
@@ -492,9 +488,10 @@ export function Sidebar({ open, onToggle }) {
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 text-xs text-dark-500">
|
||||
<div>Claude Code Web UI</div>
|
||||
<div>Multi-Session Mode</div>
|
||||
<div className="p-4 border-t border-dark-800">
|
||||
<div className="text-xs text-dark-600 text-center">
|
||||
Claude Code Web UI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 { useHosts } from '../contexts/HostContext';
|
||||
|
||||
// Resizable divider - uses DOM manipulation during drag for smooth resize
|
||||
const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
||||
@@ -96,49 +97,50 @@ const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
||||
);
|
||||
});
|
||||
|
||||
// Panel wrapper with header
|
||||
const PanelWrapper = memo(function PanelWrapper({ sessionId, children, onRemove, onMaximize }) {
|
||||
const { sessions, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
||||
const session = sessions[sessionId];
|
||||
const isFocused = focusedSessionId === sessionId;
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Get host color
|
||||
const getHostColor = () => {
|
||||
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}`;
|
||||
};
|
||||
// Custom comparison for PanelWrapper - only re-render when specific fields change
|
||||
const panelWrapperPropsAreEqual = (prev, next) => {
|
||||
return (
|
||||
prev.sessionId === next.sessionId &&
|
||||
prev.isFocused === next.isFocused &&
|
||||
prev.hostColor === next.hostColor &&
|
||||
prev.displayName === next.displayName &&
|
||||
prev.isActive === next.isActive &&
|
||||
prev.isProcessing === next.isProcessing &&
|
||||
prev.children === next.children
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col h-full overflow-hidden rounded-lg border
|
||||
${isFocused ? 'border-orange-500/50' : 'border-dark-700'}
|
||||
`}
|
||||
onClick={() => setFocusedSessionId(sessionId)}
|
||||
onClick={() => onFocus(sessionId)}
|
||||
>
|
||||
{/* Mini header */}
|
||||
<div
|
||||
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">
|
||||
{getDisplayName()}
|
||||
{displayName}
|
||||
</span>
|
||||
{session.isProcessing && (
|
||||
{isProcessing && (
|
||||
<span className="text-[10px] text-orange-400 animate-pulse">Processing...</span>
|
||||
)}
|
||||
<button
|
||||
@@ -169,10 +171,20 @@ const PanelWrapper = memo(function PanelWrapper({ sessionId, children, onRemove,
|
||||
</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 }) {
|
||||
const { removeFromSplit, clearSplit, setFocusedSessionId } = useSessionManager();
|
||||
const { sessions, removeFromSplit, clearSplit, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
||||
const { hosts } = useHosts();
|
||||
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
||||
|
||||
// Refs for direct DOM manipulation during resize
|
||||
@@ -188,6 +200,10 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
setFocusedSessionId(sessionId);
|
||||
}, [clearSplit, setFocusedSessionId]);
|
||||
|
||||
const handleFocus = useCallback((sessionId) => {
|
||||
setFocusedSessionId(sessionId);
|
||||
}, [setFocusedSessionId]);
|
||||
|
||||
const handleHorizontalResizeEnd = useCallback((percentage) => {
|
||||
setSizes(prev => ({ ...prev, h: percentage }));
|
||||
}, []);
|
||||
@@ -196,19 +212,34 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
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;
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
// Single panel (shouldn't happen in split mode, but handle it)
|
||||
if (count === 1) {
|
||||
const props = getPanelProps(splitSessions[0]);
|
||||
if (!props) return null;
|
||||
return (
|
||||
<div className="h-full p-1">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props}>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -217,6 +248,9 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
|
||||
// Two panels - horizontal split
|
||||
if (count === 2) {
|
||||
const props0 = getPanelProps(splitSessions[0]);
|
||||
const props1 = getPanelProps(splitSessions[1]);
|
||||
if (!props0 || !props1) return null;
|
||||
return (
|
||||
<div className="h-full flex p-1 gap-0">
|
||||
<div
|
||||
@@ -224,11 +258,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
style={{ width: `${sizes.h}%` }}
|
||||
className="min-w-0 h-full"
|
||||
>
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props0}>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -238,11 +268,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[1]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props1}>
|
||||
{renderPanel(splitSessions[1])}
|
||||
</PanelWrapper>
|
||||
</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) {
|
||||
const props0 = getPanelProps(splitSessions[0]);
|
||||
const props1 = getPanelProps(splitSessions[1]);
|
||||
const props2 = getPanelProps(splitSessions[2]);
|
||||
if (!props0 || !props1 || !props2) return null;
|
||||
return (
|
||||
<div className="h-full flex p-1 gap-0">
|
||||
{/* Left column - 2 panels stacked */}
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
style={{ width: `${sizes.h}%` }}
|
||||
className="flex flex-col min-w-0"
|
||||
style={{ width: '33.33%' }}
|
||||
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}
|
||||
>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</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>
|
||||
<PanelWrapper {...props0}>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
|
||||
<Divider
|
||||
direction="horizontal"
|
||||
panelRef={leftPanelRef}
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
|
||||
{/* Right column - 1 panel full height */}
|
||||
<div style={{ flex: 1 }} className="min-w-0">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[2]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<div
|
||||
ref={topPanelRef}
|
||||
style={{ width: '33.33%' }}
|
||||
className="min-w-0 h-full"
|
||||
>
|
||||
<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])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -311,6 +323,11 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
|
||||
// Four panels - 2x2 grid
|
||||
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 (
|
||||
<div className="h-full flex flex-col p-1 gap-0">
|
||||
{/* Top row */}
|
||||
@@ -324,11 +341,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
style={{ width: `${sizes.h}%` }}
|
||||
className="min-w-0 h-full"
|
||||
>
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props0}>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -338,11 +351,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[1]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props1}>
|
||||
{renderPanel(splitSessions[1])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -357,11 +366,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
{/* Bottom row */}
|
||||
<div style={{ flex: 1 }} className="flex min-h-0">
|
||||
<div style={{ width: `${sizes.h}%` }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[2]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props2}>
|
||||
{renderPanel(splitSessions[2])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
@@ -371,11 +376,7 @@ export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[3]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
<PanelWrapper {...props3}>
|
||||
{renderPanel(splitSessions[3])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { useState, useRef, useCallback, memo } from 'react';
|
||||
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle } from 'lucide-react';
|
||||
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 { 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
|
||||
const Tab = memo(function Tab({
|
||||
@@ -8,6 +28,7 @@ const Tab = memo(function Tab({
|
||||
isActive,
|
||||
isSplit,
|
||||
index,
|
||||
hostColor,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
@@ -30,15 +51,6 @@ const Tab = memo(function Tab({
|
||||
return `${hostName}: ${context}`;
|
||||
};
|
||||
|
||||
// Get host color
|
||||
const getHostColor = () => {
|
||||
switch (session.host) {
|
||||
case 'neko': return '#f97316';
|
||||
case 'mochi': return '#22c55e';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setEditName(session.name || '');
|
||||
setIsEditing(true);
|
||||
@@ -78,7 +90,7 @@ const Tab = memo(function Tab({
|
||||
${isSplit ? 'ring-1 ring-inset ring-blue-500/30' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderTopColor: isActive ? getHostColor() : 'transparent',
|
||||
borderTopColor: isActive ? (hostColor || '#6b7280') : 'transparent',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
@@ -146,7 +158,7 @@ const Tab = memo(function Tab({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, tabPropsAreEqual);
|
||||
|
||||
export function TabBar() {
|
||||
const {
|
||||
@@ -165,6 +177,7 @@ export function TabBar() {
|
||||
clearSplit,
|
||||
} = useSessionManager();
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
|
||||
const handleDragStart = useCallback((e, index) => {
|
||||
@@ -202,9 +215,39 @@ export function TabBar() {
|
||||
}
|
||||
}, [splitSessions, addToSplit, removeFromSplit]);
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
createSession();
|
||||
}, [createSession]);
|
||||
const [showNewTabMenu, setShowNewTabMenu] = useState(false);
|
||||
const newTabMenuRef = useRef(null);
|
||||
|
||||
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
|
||||
const getSplitInfo = () => {
|
||||
@@ -217,7 +260,7 @@ export function TabBar() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex items-end gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
|
||||
{tabOrder.map((sessionId, index) => {
|
||||
@@ -231,6 +274,7 @@ export function TabBar() {
|
||||
isActive={focusedSessionId === sessionId}
|
||||
isSplit={splitSessions.includes(sessionId)}
|
||||
index={index}
|
||||
hostColor={hosts[session.host]?.color}
|
||||
onDragStart={handleDragStart}
|
||||
onRename={renameSession}
|
||||
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
|
||||
onClick={handleNewTab}
|
||||
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"
|
||||
onClick={() => setShowNewTabMenu(!showNewTabMenu)}
|
||||
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)"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</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>
|
||||
|
||||
{/* Split view controls */}
|
||||
|
||||
@@ -903,6 +903,13 @@ export function SessionProvider({ children }) {
|
||||
// Handle file uploads if needed
|
||||
let uploadedFiles = [];
|
||||
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();
|
||||
for (const file of attachments) {
|
||||
formData.append('files', file.file);
|
||||
@@ -914,6 +921,13 @@ export function SessionProvider({ children }) {
|
||||
body: formData,
|
||||
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();
|
||||
uploadedFiles = data.files || [];
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user