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:
2025-12-20 15:34:46 +01:00
parent 165a7729a1
commit 580273bed0
8 changed files with 307 additions and 176 deletions

View File

@@ -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' });
});

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

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

View File

@@ -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,47 +215,34 @@ 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'
}}
{(() => {
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-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" />
)}
</button>
);
})}
</div>
<Server className="w-4 h-4" style={{ color: currentHostConfig.color }} />
<div className="flex-1">
<span className="text-white font-medium">{currentHostConfig.name}</span>
{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 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 */}
<div className="space-y-3">
@@ -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>

View File

@@ -1,6 +1,7 @@
import { memo, useState, useRef, useCallback, useEffect } from 'react';
import { X, Maximize2, GripHorizontal, GripVertical } from 'lucide-react';
import { useSessionManager } from '../contexts/SessionContext';
import { 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"
>
<div
ref={topPanelRef}
style={{ height: `${sizes.v}%` }}
className="min-h-0"
>
<PanelWrapper
sessionId={splitSessions[0]}
onRemove={handleRemove}
onMaximize={handleMaximize}
style={{ width: '33.33%' }}
className="min-w-0 h-full"
>
<PanelWrapper {...props0}>
{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>
</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>

View File

@@ -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 */}

View File

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