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 (
-
!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'
- }}
- >
-
- {host.name}
- {sessionActive && isSelected && (
-
+ {(() => {
+ const currentHostConfig = hosts.find(h => h.id === currentHost);
+ return currentHostConfig ? (
+
+
+
+ {currentHostConfig.name}
+ {sessionActive && (
+
)}
-
- );
- })}
-
- {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
+
+
{
+ 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"
+ >
+
+ Reset All Sessions
+
@@ -492,9 +488,10 @@ export function Sidebar({ open, onToggle }) {
)}
{/* Footer */}
-
-
Claude Code Web UI
-
Multi-Session Mode
+
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 */}
+
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)"
>
+
+
+ {showNewTabMenu && (
+
+ {Object.entries(hosts).map(([hostId, host]) => (
+ handleNewTab(hostId)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors text-sm"
+ >
+
+ {host.name}
+
+ ))}
+
+ )}
{/* 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) {