diff --git a/backend/server.js b/backend/server.js index 50d30eb..62b6ffc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -175,7 +175,9 @@ app.get('/api/hosts', requireAuth, (req, res) => { color: host.color, icon: host.icon, 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' }); }); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index db6af19..38e3853 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -19,6 +19,8 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Always HTTPS since NPM handles SSL termination (required for secure cookies) proxy_set_header X-Forwarded-Proto https; + # Allow file uploads up to 100MB + client_max_body_size 100m; } # Auth routes proxy diff --git a/frontend/public/tanuki-avatar.png b/frontend/public/tanuki-avatar.png new file mode 100644 index 0000000..6b993bf Binary files /dev/null and b/frontend/public/tanuki-avatar.png differ diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 3c2e489..2304484 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -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 }) { )} ); -} +}, 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
{renderContent()}
; -} +}); // Tool configuration with icons, colors, and display logic const TOOL_CONFIG = { diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 358f161..d2a6e42 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 }) {
- {/* Host Selection */} + {/* Host Display (read-only - host is fixed per session) */}

Host

-
- {hosts.map((host) => { - const isSelected = currentHost === host.id; - const isDisabled = sessionActive && currentHost !== host.id; - return ( - - ); - })} -
- {sessionActive && ( -

Stop session to switch hosts

- )} +
+
+ ) : ( +
Loading...
+ ); + })()} +

+ Host is fixed per session. Create a new tab for a different host. +

{/* Working Directory */} @@ -446,6 +427,21 @@ export function Sidebar({ open, onToggle }) { Clear Messages + + @@ -492,9 +488,10 @@ export function Sidebar({ open, onToggle }) { )} {/* Footer */} -
-
Claude Code Web UI
-
Multi-Session Mode
+
+
+ Claude Code Web UI +
diff --git a/frontend/src/components/SplitLayout.jsx b/frontend/src/components/SplitLayout.jsx index 52a8e4a..3f17538 100644 --- a/frontend/src/components/SplitLayout.jsx +++ b/frontend/src/components/SplitLayout.jsx @@ -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 (
setFocusedSessionId(sessionId)} + onClick={() => onFocus(sessionId)} > {/* Mini header */}
- + - {getDisplayName()} + {displayName} - {session.isProcessing && ( + {isProcessing && ( Processing... )}
); -}); +}, 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 (
- + {renderPanel(splitSessions[0])}
@@ -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 (
- + {renderPanel(splitSessions[0])}
@@ -238,11 +268,7 @@ export function SplitLayout({ splitSessions, renderPanel }) { onResizeEnd={handleHorizontalResizeEnd} />
- + {renderPanel(splitSessions[1])}
@@ -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 (
- {/* Left column - 2 panels stacked */}
-
- - {renderPanel(splitSessions[0])} - -
- -
- - {renderPanel(splitSessions[1])} - -
+ + {renderPanel(splitSessions[0])} +
- - - {/* Right column - 1 panel full height */} -
- +
+ + {renderPanel(splitSessions[1])} + +
+ +
+ {renderPanel(splitSessions[2])}
@@ -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 (
{/* Top row */} @@ -324,11 +341,7 @@ export function SplitLayout({ splitSessions, renderPanel }) { style={{ width: `${sizes.h}%` }} className="min-w-0 h-full" > - + {renderPanel(splitSessions[0])}
@@ -338,11 +351,7 @@ export function SplitLayout({ splitSessions, renderPanel }) { onResizeEnd={handleHorizontalResizeEnd} />
- + {renderPanel(splitSessions[1])}
@@ -357,11 +366,7 @@ export function SplitLayout({ splitSessions, renderPanel }) { {/* Bottom row */}
- + {renderPanel(splitSessions[2])}
@@ -371,11 +376,7 @@ export function SplitLayout({ splitSessions, renderPanel }) { onResizeEnd={handleHorizontalResizeEnd} />
- + {renderPanel(splitSessions[3])}
diff --git a/frontend/src/components/TabBar.jsx b/frontend/src/components/TabBar.jsx index 10db955..1b067ac 100644 --- a/frontend/src/components/TabBar.jsx +++ b/frontend/src/components/TabBar.jsx @@ -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({
); -}); +}, 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 ( -
+
{/* Tabs */}
{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() { /> ); })} +
- {/* New tab button */} + {/* New tab button with host dropdown - outside scrollable area */} +
+ + {showNewTabMenu && ( +
+ {Object.entries(hosts).map(([hostId, host]) => ( + + ))} +
+ )}
{/* Split view controls */} diff --git a/frontend/src/contexts/SessionContext.jsx b/frontend/src/contexts/SessionContext.jsx index 17e296d..0afe994 100644 --- a/frontend/src/contexts/SessionContext.jsx +++ b/frontend/src/contexts/SessionContext.jsx @@ -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) {