feat: Multi-session support with tabs, split view, and Mochi integration
- Add SessionContext for central state management - Add TabBar component for session tabs - Add SplitLayout for side-by-side session viewing - Add ChatPanel wrapper component - Refactor ChatInput to uncontrolled input for performance - Add SCP file transfer for SSH hosts (Mochi) - Fix stats undefined crash on session restore - Store host info in sessions for upload routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,57 +47,30 @@ const COMMANDS = [
|
||||
];
|
||||
|
||||
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
||||
const [inputHistory, setInputHistory] = useState(() => loadHistory());
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [savedInput, setSavedInput] = useState('');
|
||||
const [showHistorySearch, setShowHistorySearch] = useState(false);
|
||||
const [historySearchResults, setHistorySearchResults] = useState([]);
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
// Use uncontrolled input for performance - no React re-render on every keystroke
|
||||
const textareaRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
// Handle command filtering
|
||||
useEffect(() => {
|
||||
if (message.startsWith('/')) {
|
||||
const query = message.slice(1).toLowerCase();
|
||||
const filtered = COMMANDS.filter(cmd =>
|
||||
cmd.name.toLowerCase().startsWith(query)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
setShowCommands(filtered.length > 0 && message.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
setShowCommands(false);
|
||||
}
|
||||
}, [message]);
|
||||
// These states don't change on every keystroke, so they're fine
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
||||
const [inputHistory] = useState(() => loadHistory());
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [savedInput, setSavedInput] = useState('');
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
|
||||
// Add message to history
|
||||
const addToHistory = useCallback((msg) => {
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setInputHistory(prev => {
|
||||
// Remove duplicate if exists
|
||||
const filtered = prev.filter(h => h !== trimmed);
|
||||
const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(newHistory);
|
||||
return newHistory;
|
||||
});
|
||||
const history = loadHistory();
|
||||
const filtered = history.filter(h => h !== trimmed);
|
||||
const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(newHistory);
|
||||
}, []);
|
||||
|
||||
// Validate file
|
||||
@@ -131,7 +104,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview for images
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const fileData = {
|
||||
file,
|
||||
@@ -208,7 +180,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
if (files && files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = '';
|
||||
}, [processFiles]);
|
||||
|
||||
@@ -221,11 +192,19 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get current message from textarea (uncontrolled)
|
||||
const getMessage = () => textareaRef.current?.value || '';
|
||||
const setMessage = (val) => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.value = val;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const message = getMessage();
|
||||
if (message.trim() || attachedFiles.length > 0) {
|
||||
addToHistory(message);
|
||||
// Pass both message and files to onSend
|
||||
onSend(message, attachedFiles);
|
||||
setMessage('');
|
||||
setAttachedFiles([]);
|
||||
@@ -242,13 +221,21 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
// Select from history search
|
||||
const selectHistoryItem = (item) => {
|
||||
setMessage(item);
|
||||
setShowHistorySearch(false);
|
||||
setHistorySearchResults([]);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
// Handle input changes for command detection (debounced check, not on every key)
|
||||
const handleInput = useCallback(() => {
|
||||
const message = getMessage();
|
||||
if (message.startsWith('/')) {
|
||||
const query = message.slice(1).toLowerCase();
|
||||
const filtered = COMMANDS.filter(cmd =>
|
||||
cmd.name.toLowerCase().startsWith(query)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
setShowCommands(filtered.length > 0 && message.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else if (showCommands) {
|
||||
setShowCommands(false);
|
||||
}
|
||||
}, [showCommands]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// ESC to stop generation
|
||||
@@ -262,29 +249,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
setShowCommands(false);
|
||||
return;
|
||||
}
|
||||
if (showHistorySearch) {
|
||||
setShowHistorySearch(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle history search results navigation
|
||||
if (showHistorySearch && historySearchResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.min(i + 1, historySearchResults.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
selectHistoryItem(historySearchResults[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle command selection
|
||||
@@ -306,10 +270,10 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow up/down for history navigation (only when not in command/search mode)
|
||||
if (!showCommands && !showHistorySearch && inputHistory.length > 0) {
|
||||
// Arrow up/down for history navigation
|
||||
if (!showCommands && inputHistory.length > 0) {
|
||||
const message = getMessage();
|
||||
if (e.key === 'ArrowUp') {
|
||||
// Only navigate history if at start of input or input is empty
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea && (textarea.selectionStart === 0 || message === '')) {
|
||||
e.preventDefault();
|
||||
@@ -465,49 +429,19 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History search results dropdown */}
|
||||
{showHistorySearch && historySearchResults.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10 max-h-64 overflow-y-auto">
|
||||
<div className="px-3 py-2 text-xs text-dark-500 border-b border-dark-700 flex items-center gap-2">
|
||||
<History className="w-3 h-3" />
|
||||
History search results ({historySearchResults.length})
|
||||
</div>
|
||||
{historySearchResults.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => selectHistoryItem(item)}
|
||||
className={`w-full px-4 py-2.5 text-left transition-colors text-sm truncate
|
||||
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
||||
>
|
||||
{item.length > 100 ? item.slice(0, 100) + '...' : item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setHistoryIndex(-1); // Reset history navigation on manual edit
|
||||
}}
|
||||
defaultValue=""
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={`
|
||||
w-full bg-dark-800 border border-dark-700 rounded-xl
|
||||
px-4 py-3 pr-12 text-dark-100 placeholder-dark-500
|
||||
focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20
|
||||
resize-none transition-colors
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
className="w-full bg-dark-800 border border-dark-700 rounded-xl px-4 py-3 pr-12 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20 resize-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
||||
{isProcessing ? 'ESC to stop' : attachedFiles.length > 0 ? `${attachedFiles.length} file(s)` : message.startsWith('/') ? 'Tab to complete' : '↑↓ history'}
|
||||
{isProcessing ? 'ESC to stop' : '↑↓ history'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -523,14 +457,12 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
|
||||
className={`
|
||||
p-3 rounded-xl transition-all
|
||||
${disabled || (!message.trim() && attachedFiles.length === 0)
|
||||
disabled={disabled}
|
||||
className={`p-3 rounded-xl transition-all ${
|
||||
disabled
|
||||
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
|
||||
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
|
||||
}
|
||||
`}
|
||||
}`}
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
197
frontend/src/components/ChatPanel.jsx
Normal file
197
frontend/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { PermissionDialog } from './PermissionDialog';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
||||
const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||
onSend, onStop, disabled, isProcessing, sessionId, connected, active
|
||||
}) {
|
||||
const placeholder = !connected
|
||||
? 'Connecting...'
|
||||
: !active
|
||||
? 'Start session to begin'
|
||||
: 'Type your message...';
|
||||
|
||||
return (
|
||||
<ChatInput
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
disabled={disabled}
|
||||
isProcessing={isProcessing}
|
||||
sessionId={sessionId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison - only re-render if these specific props change
|
||||
return (
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.isProcessing === nextProps.isProcessing &&
|
||||
prevProps.sessionId === nextProps.sessionId &&
|
||||
prevProps.connected === nextProps.connected &&
|
||||
prevProps.active === nextProps.active &&
|
||||
prevProps.onSend === nextProps.onSend &&
|
||||
prevProps.onStop === nextProps.onStop
|
||||
);
|
||||
});
|
||||
|
||||
// Welcome screen when no messages
|
||||
const WelcomeScreen = memo(function WelcomeScreen({ session, onStart }) {
|
||||
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||
const projectName = session.project.split('/').pop() || session.project;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
|
||||
<div className="text-center text-dark-500 max-w-md">
|
||||
<Bot className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<h2 className="text-xl font-semibold mb-2 text-dark-300">
|
||||
{hostName}: {projectName}
|
||||
</h2>
|
||||
<p className="text-sm mb-6">
|
||||
{session.active
|
||||
? 'Session is active. Start chatting with Claude.'
|
||||
: 'Click "Start Session" in the sidebar or press the button below to begin.'}
|
||||
</p>
|
||||
{!session.active && (
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={!session.connected}
|
||||
className={`
|
||||
px-6 py-3 rounded-lg font-medium transition-colors
|
||||
${session.connected
|
||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||
: 'bg-dark-700 text-dark-500 cursor-not-allowed'}
|
||||
`}
|
||||
>
|
||||
{session.connected ? 'Start Session' : 'Connecting...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Error banner
|
||||
const ErrorBanner = memo(function ErrorBanner({ error, onClear }) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-red-900/50 border-b border-red-800 px-4 py-2 flex justify-between items-center flex-shrink-0">
|
||||
<span className="text-red-200 text-sm">{error}</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-red-400 hover:text-red-300 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Use a separate hook that memoizes everything to prevent unnecessary re-renders
|
||||
function useMemoizedSession(sessionId) {
|
||||
const manager = useSessionManager();
|
||||
const session = manager.sessions[sessionId];
|
||||
const messages = manager.sessionMessages[sessionId] || [];
|
||||
|
||||
// Memoize the combined session object
|
||||
const sessionWithMessages = useMemo(() => {
|
||||
return session ? { ...session, messages } : null;
|
||||
}, [session, messages]);
|
||||
|
||||
// Memoize all action functions
|
||||
const actions = useMemo(() => ({
|
||||
start: () => manager.startClaudeSession(sessionId),
|
||||
stop: () => manager.stopClaudeSession(sessionId),
|
||||
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
||||
stopGeneration: () => manager.stopGeneration(sessionId),
|
||||
clearMessages: () => manager.clearMessages(sessionId),
|
||||
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
||||
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
||||
}), [sessionId, manager]);
|
||||
|
||||
return { session: sessionWithMessages, ...actions };
|
||||
}
|
||||
|
||||
export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
const {
|
||||
session,
|
||||
start,
|
||||
stop,
|
||||
send,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
changePermissionMode,
|
||||
respondToPermission,
|
||||
} = useMemoizedSession(sessionId);
|
||||
|
||||
const handleClearError = useCallback(() => {
|
||||
// We'd need to add this to the session context
|
||||
// For now, errors auto-clear on next action
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = useCallback((message, attachments = []) => {
|
||||
if (message.trim() || attachments.length > 0) {
|
||||
send(message, attachments);
|
||||
}
|
||||
}, [send]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-dark-500">
|
||||
Session not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasMessages = session.messages && session.messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-dark-950">
|
||||
{/* Error Banner */}
|
||||
<ErrorBanner error={session.error} onClear={handleClearError} />
|
||||
|
||||
{/* Messages or Welcome */}
|
||||
{hasMessages || session.active ? (
|
||||
<MessageList
|
||||
messages={session.messages || []}
|
||||
isProcessing={session.isProcessing}
|
||||
/>
|
||||
) : (
|
||||
<WelcomeScreen session={session} onStart={start} />
|
||||
)}
|
||||
|
||||
{/* Status Bar */}
|
||||
<StatusBar
|
||||
sessionStats={session.stats}
|
||||
isProcessing={session.isProcessing}
|
||||
connected={session.connected}
|
||||
permissionMode={session.permissionMode}
|
||||
controlInitialized={session.controlInitialized}
|
||||
onChangeMode={(mode) => changePermissionMode(mode)}
|
||||
/>
|
||||
|
||||
{/* Input - memoized props to prevent re-renders during streaming */}
|
||||
<MemoizedChatInput
|
||||
onSend={handleSendMessage}
|
||||
onStop={stopGeneration}
|
||||
disabled={!session.active}
|
||||
isProcessing={session.isProcessing}
|
||||
sessionId={session.claudeSessionId}
|
||||
connected={session.connected}
|
||||
active={session.active}
|
||||
/>
|
||||
|
||||
{/* Permission Dialog */}
|
||||
<PermissionDialog
|
||||
permission={session.pendingPermission}
|
||||
onAllow={(requestId) => respondToPermission(requestId, true)}
|
||||
onDeny={(requestId) => respondToPermission(requestId, false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -72,7 +72,7 @@ function SystemHints({ reminders, inline = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isProcessing }) {
|
||||
export const MessageList = memo(function MessageList({ messages, isProcessing }) {
|
||||
const containerRef = useRef(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
@@ -215,7 +215,7 @@ export function MessageList({ messages, isProcessing }) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Message = memo(function Message({ message }) {
|
||||
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||
@@ -28,31 +29,23 @@ function saveRecentDirs(dirs) {
|
||||
function addRecentDir(hostId, path) {
|
||||
const recent = loadRecentDirs();
|
||||
const hostRecent = recent[hostId] || [];
|
||||
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = hostRecent.filter(p => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
|
||||
|
||||
recent[hostId] = updated;
|
||||
saveRecentDirs(recent);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
open,
|
||||
onToggle,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
sessionActive,
|
||||
activeHost,
|
||||
onStartSession,
|
||||
onStopSession,
|
||||
onClearMessages,
|
||||
resumeSession,
|
||||
onToggleResume
|
||||
}) {
|
||||
export function Sidebar({ open, onToggle }) {
|
||||
const {
|
||||
focusedSessionId,
|
||||
focusedSession,
|
||||
startClaudeSession,
|
||||
stopClaudeSession,
|
||||
clearMessages,
|
||||
updateSessionConfig,
|
||||
} = useSessionManager();
|
||||
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [recentDirs, setRecentDirs] = useState([]);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
@@ -62,43 +55,59 @@ export function Sidebar({
|
||||
const [browserError, setBrowserError] = useState(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
// Current session values
|
||||
const currentHost = focusedSession?.host || 'neko';
|
||||
const currentProject = focusedSession?.project || '/home/sumdex/projects';
|
||||
const sessionActive = focusedSession?.active || false;
|
||||
const resumeSession = focusedSession?.resumeOnStart ?? true;
|
||||
|
||||
// Fetch hosts on mount
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/hosts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setHosts(data.hosts || []);
|
||||
if (!selectedHost && data.defaultHost) {
|
||||
onSelectHost(data.defaultHost);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load recent directories when host changes
|
||||
useEffect(() => {
|
||||
if (!selectedHost) return;
|
||||
if (!currentHost) return;
|
||||
const recent = loadRecentDirs();
|
||||
setRecentDirs(recent[selectedHost] || []);
|
||||
}, [selectedHost]);
|
||||
setRecentDirs(recent[currentHost] || []);
|
||||
}, [currentHost]);
|
||||
|
||||
// Handle selecting a directory (from dropdown or browser)
|
||||
// Handle selecting a directory
|
||||
const handleSelectDir = useCallback((path) => {
|
||||
onSelectProject(path);
|
||||
const updated = addRecentDir(selectedHost, path);
|
||||
if (!focusedSessionId) return;
|
||||
updateSessionConfig(focusedSessionId, { project: path });
|
||||
const updated = addRecentDir(currentHost, path);
|
||||
setRecentDirs(updated);
|
||||
setDropdownOpen(false);
|
||||
setShowBrowser(false);
|
||||
}, [selectedHost, onSelectProject]);
|
||||
}, [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;
|
||||
updateSessionConfig(focusedSessionId, { resumeOnStart: !resumeSession });
|
||||
}, [focusedSessionId, resumeSession, updateSessionConfig]);
|
||||
|
||||
// Browse directories on host
|
||||
const browsePath = useCallback(async (path) => {
|
||||
if (!selectedHost) return;
|
||||
if (!currentHost) return;
|
||||
setBrowserLoading(true);
|
||||
setBrowserError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/browse?host=${selectedHost}&path=${encodeURIComponent(path)}`);
|
||||
const res = await fetch(`${API_URL}/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -113,7 +122,7 @@ export function Sidebar({
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [selectedHost]);
|
||||
}, [currentHost]);
|
||||
|
||||
// Open browser
|
||||
const openBrowser = useCallback(() => {
|
||||
@@ -128,6 +137,51 @@ export function Sidebar({
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
// Start/stop session handlers
|
||||
const handleStartSession = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
startClaudeSession(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, startClaudeSession]);
|
||||
|
||||
const handleStopSession = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
stopClaudeSession(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, stopClaudeSession]);
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
clearMessages(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, clearMessages]);
|
||||
|
||||
// No session selected
|
||||
if (!focusedSession) {
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
${open ? 'w-72' : 'w-0'}
|
||||
bg-dark-900 border-r border-dark-800 flex flex-col
|
||||
transition-all duration-300 overflow-hidden
|
||||
lg:relative fixed inset-y-0 left-0 z-40
|
||||
`}
|
||||
>
|
||||
<div className="p-4 border-b border-dark-800">
|
||||
<h2 className="font-semibold text-dark-200 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Session Control
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-dark-500 text-sm text-center">
|
||||
Create or select a session tab to configure
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
@@ -152,30 +206,30 @@ export function Sidebar({
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{hosts.map((host) => {
|
||||
const isActive = sessionActive && activeHost === host.id;
|
||||
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
|
||||
const isSelected = currentHost === host.id;
|
||||
const isDisabled = sessionActive && currentHost !== host.id;
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => !isDisabled && onSelectHost(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'
|
||||
: selectedHost === host.id
|
||||
: isSelected
|
||||
? 'border-orange-500/50 text-white'
|
||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||
backgroundColor: isSelected ? `${host.color}30` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
||||
<span>{host.name}</span>
|
||||
{isActive && (
|
||||
{sessionActive && isSelected && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||
)}
|
||||
</button>
|
||||
@@ -198,13 +252,17 @@ export function Sidebar({
|
||||
<div className="flex gap-2">
|
||||
{/* Dropdown button */}
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left hover:border-dark-600 transition-colors"
|
||||
onClick={() => !sessionActive && setDropdownOpen(!dropdownOpen)}
|
||||
disabled={sessionActive}
|
||||
className={`
|
||||
flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left transition-colors
|
||||
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:border-dark-600'}
|
||||
`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
|
||||
<div className="text-sm text-dark-200 truncate">{getDisplayName(currentProject)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{currentProject}</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -212,7 +270,11 @@ export function Sidebar({
|
||||
{/* Browse button */}
|
||||
<button
|
||||
onClick={openBrowser}
|
||||
className="px-3 py-2.5 bg-dark-800 hover:bg-dark-700 border border-dark-700 rounded-lg text-dark-400 hover:text-dark-200 transition-colors"
|
||||
disabled={sessionActive}
|
||||
className={`
|
||||
px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-dark-400 transition-colors
|
||||
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:bg-dark-700 hover:text-dark-200'}
|
||||
`}
|
||||
title="Browse directories"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -220,7 +282,7 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{dropdownOpen && (
|
||||
{dropdownOpen && !sessionActive && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
||||
{recentDirs.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-dark-500 text-center">
|
||||
@@ -232,14 +294,14 @@ export function Sidebar({
|
||||
key={path}
|
||||
onClick={() => handleSelectDir(path)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
|
||||
${selectedProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
|
||||
${currentProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{getDisplayName(path)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{path}</div>
|
||||
</div>
|
||||
{selectedProject === path && (
|
||||
{currentProject === path && (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
@@ -249,11 +311,15 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionActive && (
|
||||
<p className="text-xs text-dark-500">Stop session to change directory</p>
|
||||
)}
|
||||
|
||||
{/* Resume toggle */}
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<div
|
||||
onClick={onToggleResume}
|
||||
onClick={handleToggleResume}
|
||||
className={`
|
||||
relative w-10 h-5 rounded-full transition-colors
|
||||
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
|
||||
@@ -282,17 +348,21 @@ export function Sidebar({
|
||||
<div className="space-y-2">
|
||||
{!sessionActive ? (
|
||||
<button
|
||||
onClick={onStartSession}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-green-600 hover:bg-green-500 rounded-lg
|
||||
font-medium transition-colors"
|
||||
onClick={handleStartSession}
|
||||
disabled={!focusedSession?.connected}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-colors
|
||||
${focusedSession?.connected
|
||||
? 'bg-green-600 hover:bg-green-500'
|
||||
: 'bg-dark-700 text-dark-500 cursor-not-allowed'}
|
||||
`}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Session
|
||||
{focusedSession?.connected ? 'Start Session' : 'Connecting...'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStopSession}
|
||||
onClick={handleStopSession}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-red-600 hover:bg-red-500 rounded-lg
|
||||
font-medium transition-colors"
|
||||
@@ -303,7 +373,7 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClearMessages}
|
||||
onClick={handleClearMessages}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
||||
bg-dark-800 hover:bg-dark-700 rounded-lg
|
||||
text-dark-300 hover:text-dark-100 transition-colors"
|
||||
@@ -317,8 +387,8 @@ export function Sidebar({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
||||
<div>Claude Code Web UI POC</div>
|
||||
<div>JSON Stream Mode</div>
|
||||
<div>Claude Code Web UI</div>
|
||||
<div>Multi-Session Mode</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Browser Modal */}
|
||||
|
||||
388
frontend/src/components/SplitLayout.jsx
Normal file
388
frontend/src/components/SplitLayout.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { memo, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { X, Maximize2, GripHorizontal, GripVertical } from 'lucide-react';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
|
||||
// Resizable divider - uses DOM manipulation during drag for smooth resize
|
||||
const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startPos = useRef(0);
|
||||
const startSize = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
setIsDragging(true);
|
||||
startPos.current = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||
startSize.current = direction === 'horizontal'
|
||||
? panel.offsetWidth
|
||||
: panel.offsetHeight;
|
||||
|
||||
// Add will-change for GPU acceleration
|
||||
panel.style.willChange = 'width, height';
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||
const delta = currentPos - startPos.current;
|
||||
const newSize = startSize.current + delta;
|
||||
|
||||
// Get parent size for percentage calculation
|
||||
const parent = panel.parentElement;
|
||||
const parentSize = direction === 'horizontal'
|
||||
? parent.offsetWidth
|
||||
: parent.offsetHeight;
|
||||
|
||||
// Clamp between 20% and 80%
|
||||
const minSize = parentSize * 0.2;
|
||||
const maxSize = parentSize * 0.8;
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, newSize));
|
||||
|
||||
// Direct DOM manipulation - no React re-render
|
||||
if (direction === 'horizontal') {
|
||||
panel.style.width = `${clampedSize}px`;
|
||||
} else {
|
||||
panel.style.height = `${clampedSize}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
setIsDragging(false);
|
||||
panel.style.willChange = 'auto';
|
||||
|
||||
// Calculate final percentage and update React state
|
||||
const parent = panel.parentElement;
|
||||
const parentSize = direction === 'horizontal'
|
||||
? parent.offsetWidth
|
||||
: parent.offsetHeight;
|
||||
const currentSize = direction === 'horizontal'
|
||||
? panel.offsetWidth
|
||||
: panel.offsetHeight;
|
||||
const percentage = (currentSize / parentSize) * 100;
|
||||
|
||||
// Clear inline style and let React take over
|
||||
if (direction === 'horizontal') {
|
||||
panel.style.width = '';
|
||||
} else {
|
||||
panel.style.height = '';
|
||||
}
|
||||
|
||||
onResizeEnd(percentage);
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [direction, panelRef, onResizeEnd]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`
|
||||
flex items-center justify-center bg-dark-800 hover:bg-dark-700 transition-colors flex-shrink-0
|
||||
${direction === 'horizontal' ? 'w-2 cursor-col-resize' : 'h-2 cursor-row-resize'}
|
||||
${isDragging ? 'bg-orange-500' : ''}
|
||||
`}
|
||||
>
|
||||
{direction === 'horizontal' ? (
|
||||
<GripVertical className="w-3 h-3 text-dark-600" />
|
||||
) : (
|
||||
<GripHorizontal className="w-3 h-3 text-dark-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// 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}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col h-full overflow-hidden rounded-lg border
|
||||
${isFocused ? 'border-orange-500/50' : 'border-dark-700'}
|
||||
`}
|
||||
onClick={() => setFocusedSessionId(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' }}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${session.active ? 'bg-green-500' : 'bg-dark-500'}`} />
|
||||
<span className="flex-1 text-xs font-medium text-dark-300 truncate">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
{session.isProcessing && (
|
||||
<span className="text-[10px] text-orange-400 animate-pulse">Processing...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMaximize(sessionId);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-dark-600 text-dark-500 hover:text-dark-300"
|
||||
title="Maximize"
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(sessionId);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-dark-500 hover:text-red-400"
|
||||
title="Remove from split"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function SplitLayout({ splitSessions, renderPanel }) {
|
||||
const { removeFromSplit, clearSplit, setFocusedSessionId } = useSessionManager();
|
||||
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
||||
|
||||
// Refs for direct DOM manipulation during resize
|
||||
const leftPanelRef = useRef(null);
|
||||
const topPanelRef = useRef(null);
|
||||
|
||||
const handleRemove = useCallback((sessionId) => {
|
||||
removeFromSplit(sessionId);
|
||||
}, [removeFromSplit]);
|
||||
|
||||
const handleMaximize = useCallback((sessionId) => {
|
||||
clearSplit();
|
||||
setFocusedSessionId(sessionId);
|
||||
}, [clearSplit, setFocusedSessionId]);
|
||||
|
||||
const handleHorizontalResizeEnd = useCallback((percentage) => {
|
||||
setSizes(prev => ({ ...prev, h: percentage }));
|
||||
}, []);
|
||||
|
||||
const handleVerticalResizeEnd = useCallback((percentage) => {
|
||||
setSizes(prev => ({ ...prev, v: percentage }));
|
||||
}, []);
|
||||
|
||||
const count = splitSessions.length;
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
// Single panel (shouldn't happen in split mode, but handle it)
|
||||
if (count === 1) {
|
||||
return (
|
||||
<div className="h-full p-1">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Two panels - horizontal split
|
||||
if (count === 2) {
|
||||
return (
|
||||
<div className="h-full flex p-1 gap-0">
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
style={{ width: `${sizes.h}%` }}
|
||||
className="min-w-0 h-full"
|
||||
>
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
<Divider
|
||||
direction="horizontal"
|
||||
panelRef={leftPanelRef}
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[1]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[1])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Three panels - 2 on left, 1 on right
|
||||
if (count === 3) {
|
||||
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}
|
||||
>
|
||||
{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}
|
||||
>
|
||||
{renderPanel(splitSessions[2])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Four panels - 2x2 grid
|
||||
if (count >= 4) {
|
||||
return (
|
||||
<div className="h-full flex flex-col p-1 gap-0">
|
||||
{/* Top row */}
|
||||
<div
|
||||
ref={topPanelRef}
|
||||
style={{ height: `${sizes.v}%` }}
|
||||
className="flex min-h-0"
|
||||
>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
style={{ width: `${sizes.h}%` }}
|
||||
className="min-w-0 h-full"
|
||||
>
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[0]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[0])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
<Divider
|
||||
direction="horizontal"
|
||||
panelRef={leftPanelRef}
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[1]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[1])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider
|
||||
direction="vertical"
|
||||
panelRef={topPanelRef}
|
||||
onResizeEnd={handleVerticalResizeEnd}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
{renderPanel(splitSessions[2])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
<Divider
|
||||
direction="horizontal"
|
||||
panelRef={leftPanelRef}
|
||||
onResizeEnd={handleHorizontalResizeEnd}
|
||||
/>
|
||||
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||
<PanelWrapper
|
||||
sessionId={splitSessions[3]}
|
||||
onRemove={handleRemove}
|
||||
onMaximize={handleMaximize}
|
||||
>
|
||||
{renderPanel(splitSessions[3])}
|
||||
</PanelWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
305
frontend/src/components/TabBar.jsx
Normal file
305
frontend/src/components/TabBar.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useRef, useCallback, memo } from 'react';
|
||||
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle } from 'lucide-react';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
|
||||
// Tab component
|
||||
const Tab = memo(function Tab({
|
||||
session,
|
||||
isActive,
|
||||
isSplit,
|
||||
index,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onFocus,
|
||||
onClose,
|
||||
onSplit,
|
||||
onRename,
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Get display name
|
||||
const getDisplayName = () => {
|
||||
if (session.name) return session.name;
|
||||
|
||||
// Auto-generate from host and context/project
|
||||
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||
const context = session.currentContext || session.project.split('/').pop() || 'New';
|
||||
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);
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
if (editName.trim()) {
|
||||
onRename(session.id, editName.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
onClick={() => onFocus(session.id)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 rounded-t-lg cursor-pointer
|
||||
transition-colors relative min-w-[120px] max-w-[200px]
|
||||
${isActive
|
||||
? 'bg-dark-800 text-white border-t-2'
|
||||
: 'bg-dark-900/50 text-dark-400 hover:bg-dark-800/50 hover:text-dark-300 border-t-2 border-transparent'
|
||||
}
|
||||
${isSplit ? 'ring-1 ring-inset ring-blue-500/30' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderTopColor: isActive ? getHostColor() : 'transparent',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<GripVertical className="w-3 h-3 text-dark-600 opacity-0 group-hover:opacity-100 cursor-grab flex-shrink-0" />
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Circle
|
||||
className="w-2 h-2"
|
||||
fill={session.active ? '#22c55e' : session.connected ? '#f97316' : '#6b7280'}
|
||||
stroke="none"
|
||||
/>
|
||||
{session.isProcessing && (
|
||||
<span className="absolute inset-0 w-2 h-2 rounded-full bg-orange-400 animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab name */}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-w-0 bg-dark-700 border border-dark-600 rounded px-1 py-0.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Unread badge */}
|
||||
{session.unreadCount > 0 && !isActive && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold bg-orange-500 text-white rounded-full">
|
||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Split button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplit(session.id);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-dark-600 text-dark-500 hover:text-dark-300 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
title={isSplit ? 'Remove from split' : 'Add to split view'}
|
||||
>
|
||||
<Columns className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(session.id);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-dark-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function TabBar() {
|
||||
const {
|
||||
sessions,
|
||||
tabOrder,
|
||||
splitSessions,
|
||||
focusedSessionId,
|
||||
createSession,
|
||||
removeSession,
|
||||
renameSession,
|
||||
setFocusedSessionId,
|
||||
markAsRead,
|
||||
reorderTabs,
|
||||
addToSplit,
|
||||
removeFromSplit,
|
||||
clearSplit,
|
||||
} = useSessionManager();
|
||||
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
|
||||
const handleDragStart = useCallback((e, index) => {
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e, toIndex) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||
reorderTabs(dragIndex, toIndex);
|
||||
}
|
||||
setDragIndex(null);
|
||||
}, [dragIndex, reorderTabs]);
|
||||
|
||||
const handleFocus = useCallback((sessionId) => {
|
||||
setFocusedSessionId(sessionId);
|
||||
markAsRead(sessionId);
|
||||
}, [setFocusedSessionId, markAsRead]);
|
||||
|
||||
const handleClose = useCallback((sessionId) => {
|
||||
removeSession(sessionId);
|
||||
}, [removeSession]);
|
||||
|
||||
const handleSplit = useCallback((sessionId) => {
|
||||
if (splitSessions.includes(sessionId)) {
|
||||
removeFromSplit(sessionId);
|
||||
} else {
|
||||
addToSplit(sessionId);
|
||||
}
|
||||
}, [splitSessions, addToSplit, removeFromSplit]);
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
createSession();
|
||||
}, [createSession]);
|
||||
|
||||
// Get split layout info
|
||||
const getSplitInfo = () => {
|
||||
const count = splitSessions.length;
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return '1 panel';
|
||||
if (count === 2) return '2 panels';
|
||||
if (count === 3) return '3 panels';
|
||||
return '2x2 grid';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-dark-900 border-b border-dark-800 overflow-x-auto">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-end gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
|
||||
{tabOrder.map((sessionId, index) => {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={sessionId}
|
||||
session={session}
|
||||
isActive={focusedSessionId === sessionId}
|
||||
isSplit={splitSessions.includes(sessionId)}
|
||||
index={index}
|
||||
onDragStart={handleDragStart}
|
||||
onRename={renameSession}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFocus={handleFocus}
|
||||
onClose={handleClose}
|
||||
onSplit={handleSplit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* New tab button */}
|
||||
<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"
|
||||
title="New session (Ctrl+T)"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Split view controls */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 pl-2 border-l border-dark-700">
|
||||
{splitSessions.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-dark-500">{getSplitInfo()}</span>
|
||||
<button
|
||||
onClick={clearSplit}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||
title="Exit split view"
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
<span>Single</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{splitSessions.length === 0 && tabOrder.length > 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Add first two tabs to split
|
||||
if (tabOrder.length >= 2) {
|
||||
addToSplit(tabOrder[0]);
|
||||
addToSplit(tabOrder[1]);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||
title="Split view"
|
||||
>
|
||||
<Columns className="w-3 h-3" />
|
||||
<span>Split</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{splitSessions.length >= 2 && splitSessions.length < 4 && tabOrder.length > splitSessions.length && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Find next tab not in split
|
||||
const nextTab = tabOrder.find(id => !splitSessions.includes(id));
|
||||
if (nextTab) addToSplit(nextTab);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||
title="Add panel"
|
||||
>
|
||||
<Grid2X2 className="w-3 h-3" />
|
||||
<span>+Panel</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user