diff --git a/backend/server.js b/backend/server.js index 3239894..42f832f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -304,28 +304,60 @@ app.get('/api/browse', async (req, res) => { }); // File upload endpoint -app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => { +app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } - const uploadedFiles = req.files.map(file => { + const sessionId = req.params.sessionId; + const session = sessions.get(sessionId); + const isSSH = session?.host?.connection?.type === 'ssh'; + + const uploadedFiles = []; + + for (const file of req.files) { // Convert container path to host path for Claude // /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/... - const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/'); - return { + let hostPath = file.path.replace('/projects/', '/home/sumdex/projects/'); + + // For SSH hosts, transfer file via SCP + if (isSSH && session.host) { + const { host: sshHost, user, port = 22 } = session.host.connection; + const remotePath = `/tmp/.claude-uploads/${file.filename}`; + + try { + // Create remote directory if needed + const { execSync } = await import('child_process'); + execSync(`ssh -o StrictHostKeyChecking=no -p ${port} ${user}@${sshHost} "mkdir -p /tmp/.claude-uploads"`, { + timeout: 10000 + }); + + // Transfer file via SCP + execSync(`scp -o StrictHostKeyChecking=no -P ${port} "${file.path}" ${user}@${sshHost}:"${remotePath}"`, { + timeout: 60000 // 60s for large files + }); + + hostPath = remotePath; + console.log(`[Upload] SCP transferred ${file.filename} to ${session.hostId}:${remotePath}`); + } catch (scpErr) { + console.error(`[Upload] SCP error for ${file.filename}:`, scpErr.message); + // Fall back to local path (won't work but at least doesn't fail) + } + } + + uploadedFiles.push({ originalName: file.originalname, savedName: file.filename, - path: hostPath, // Use host path so Claude can read it - containerPath: file.path, // Keep container path for reference + path: hostPath, + containerPath: file.path, size: file.size, mimeType: file.mimetype, isImage: file.mimetype.startsWith('image/') - }; - }); + }); + } - console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`); + console.log(`[Upload] Session ${sessionId}: ${uploadedFiles.length} files uploaded`); res.json({ files: uploadedFiles }); } catch (err) { console.error('[Upload] Error:', err); @@ -582,8 +614,11 @@ wss.on('connection', (ws, req) => { const { host: sshHost, user, port = 22 } = host.connection; const sshTarget = `${user}@${sshHost}`; + // Use claudePath from config if specified, otherwise default to 'claude' + const claudeBin = host.claudePath || 'claude'; + // Build the remote command with PATH setup for non-login shells - const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${claudeArgs.join(' ')}`; + const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && ${claudeBin} ${claudeArgs.join(' ')}`; console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`); @@ -607,7 +642,7 @@ wss.on('connection', (ws, req) => { }); } - sessions.set(sessionId, { process: claudeProcess, project: projectPath }); + sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId }); sendToClient('session_started', { sessionId, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 017f508..d47b859 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,310 +1,112 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { useClaudeSession } from './hooks/useClaudeSession'; -import { MessageList } from './components/MessageList'; -import { ChatInput } from './components/ChatInput'; +import { useState, useCallback, useEffect } from 'react'; +import { SessionProvider, useSessionManager } from './contexts/SessionContext'; import { Sidebar } from './components/Sidebar'; -import { Header } from './components/Header'; -import { StatusBar } from './components/StatusBar'; -import { PermissionDialog } from './components/PermissionDialog'; +import { TabBar } from './components/TabBar'; +import { ChatPanel } from './components/ChatPanel'; +import { SplitLayout } from './components/SplitLayout'; +import { Menu } from 'lucide-react'; -// Slash command definitions -const SLASH_COMMANDS = { - clear: { - description: 'Clear chat history (UI only)', - execute: ({ clearMessages, addSystemMessage }) => { - clearMessages(); - addSystemMessage('Chat cleared'); - } - }, - help: { - description: 'Show available commands', - execute: ({ addSystemMessage }) => { - const helpText = Object.entries(SLASH_COMMANDS) - .map(([cmd, { description }]) => `/${cmd} - ${description}`) - .join('\n'); - addSystemMessage(`Available commands:\n${helpText}`); - } - }, - export: { - description: 'Export chat as Markdown', - execute: ({ messages, addSystemMessage }) => { - const markdown = messages.map(m => { - if (m.type === 'user') return `**You:** ${m.content}`; - if (m.type === 'assistant') return `**Claude:** ${m.content}`; - if (m.type === 'tool_use') return `> Tool: ${m.tool}`; - if (m.type === 'system') return `_${m.content}_`; - return ''; - }).filter(Boolean).join('\n\n'); - - const blob = new Blob([markdown], { type: 'text/markdown' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `claude-chat-${new Date().toISOString().slice(0,10)}.md`; - a.click(); - URL.revokeObjectURL(url); - addSystemMessage('Chat exported as Markdown'); - } - }, - scroll: { - description: 'Scroll to top or bottom (/scroll top|bottom)', - execute: ({ args, addSystemMessage }) => { - const direction = args[0] || 'bottom'; - const container = document.querySelector('.overflow-y-auto'); - if (container) { - if (direction === 'top') { - container.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); - } - } - addSystemMessage(`Scrolled to ${direction}`); - } - }, - new: { - description: 'Start a new session (clears history)', - execute: ({ clearMessages, stopSession, startSession, selectedProject, addSystemMessage }) => { - stopSession(); - clearMessages(); - setTimeout(() => { - startSession(selectedProject, false); // false = don't resume - }, 500); - addSystemMessage('Starting new session...'); - } - }, - info: { - description: 'Show session info', - execute: ({ connected, sessionActive, currentProject, messages, addSystemMessage }) => { - const info = [ - `Connected: ${connected ? 'Yes' : 'No'}`, - `Session: ${sessionActive ? 'Active' : 'Inactive'}`, - `Project: ${currentProject || 'None'}`, - `Messages: ${messages.length}` - ].join('\n'); - addSystemMessage(info); - } - }, - history: { - description: 'Search input history (/history )', - execute: ({ args, addSystemMessage }) => { - const HISTORY_KEY = 'claude-webui-input-history'; - try { - const stored = localStorage.getItem(HISTORY_KEY); - const history = stored ? JSON.parse(stored) : []; - const searchTerm = args.join(' ').toLowerCase(); - - if (!searchTerm) { - // Show recent history - const recent = history.slice(0, 10); - if (recent.length === 0) { - addSystemMessage('No input history found.'); - } else { - addSystemMessage(`Recent inputs:\n${recent.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`); - } - } else { - // Search history - const matches = history.filter(h => h.toLowerCase().includes(searchTerm)).slice(0, 10); - if (matches.length === 0) { - addSystemMessage(`No history entries matching "${searchTerm}"`); - } else { - addSystemMessage(`History matching "${searchTerm}":\n${matches.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`); - } - } - } catch { - addSystemMessage('Failed to read input history.'); - } - } - } -}; - -function App() { +function AppContent() { const { - connected, - sessionActive, - sessionId, - messages, - currentProject, - currentHost, - isProcessing, - error, - sessionStats, - permissionMode, - controlInitialized, - pendingPermission, - startSession, - sendMessage, - stopSession, - stopGeneration, - clearMessages, - changePermissionMode, - respondToPermission, - setError, - setMessages - } = useClaudeSession(); + sessions, + tabOrder, + splitSessions, + focusedSessionId, + createSession, + } = useSessionManager(); - const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects'); - const [selectedHost, setSelectedHost] = useState('neko'); const [sidebarOpen, setSidebarOpen] = useState(true); - const [resumeSession, setResumeSession] = useState(true); - // Add system message helper - const addSystemMessage = useCallback((content) => { - setMessages(prev => [...prev, { - type: 'system', - content, - timestamp: Date.now() - }]); - }, [setMessages]); - - const handleStartSession = useCallback(() => { - startSession(selectedProject, resumeSession, selectedHost); - }, [startSession, selectedProject, resumeSession, selectedHost]); - - const handleSelectProject = useCallback((path) => { - setSelectedProject(path); - }, []); - - const handleSelectHost = useCallback((host) => { - setSelectedHost(host); - }, []); + // Create initial session if none exists + useEffect(() => { + if (tabOrder.length === 0) { + createSession('neko', '/home/sumdex/projects'); + } + }, [tabOrder.length, createSession]); const handleToggleSidebar = useCallback(() => { setSidebarOpen(prev => !prev); }, []); - const handleToggleResume = useCallback(() => { - setResumeSession(prev => !prev); - }, []); - - const handleClearError = useCallback(() => { - setError(null); - }, [setError]); - - // Handle slash commands - const handleCommand = useCallback((command, args) => { - const cmd = SLASH_COMMANDS[command.toLowerCase()]; - if (cmd) { - cmd.execute({ - clearMessages, - addSystemMessage, - messages, - stopSession, - startSession, - selectedProject, - connected, - sessionActive, - currentProject, - args - }); - return true; - } - return false; - }, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]); - - const handleSendMessage = useCallback((message, attachedFiles = []) => { - // Check for slash command (only if no files attached) - if (message.startsWith('/') && attachedFiles.length === 0) { - const parts = message.slice(1).split(' '); - const command = parts[0]; - const args = parts.slice(1); - - if (handleCommand(command, args)) { - return; // Command handled - } else { - addSystemMessage(`Unknown command: /${command}. Type /help for available commands.`); - return; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + // Ctrl+T: New tab + if (e.ctrlKey && e.key === 't') { + e.preventDefault(); + createSession(); } - } + // Ctrl+B: Toggle sidebar + if (e.ctrlKey && e.key === 'b') { + e.preventDefault(); + setSidebarOpen(prev => !prev); + } + }; - // Regular message (with optional attachments) - if (message.trim() || attachedFiles.length > 0) { - sendMessage(message, attachedFiles); - } - }, [handleCommand, addSystemMessage, sendMessage]); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [createSession]); + + // Render panel content for a session + const renderPanel = useCallback((sessionId) => { + return ; + }, []); return (
{/* Sidebar */} - + {/* Main Content */}
-
+ {/* Header with TabBar */} +
+ {/* Sidebar toggle for mobile */} + - {/* Error Banner */} - {error && ( -
- {error} - + {/* Tab Bar */} +
+
- )} +
- {/* Messages */} - - - {/* Status Bar */} - - - {/* Input */} - + {/* Content Area */} +
+ {splitSessions.length > 0 ? ( + // Split view mode + + ) : focusedSessionId ? ( + // Single panel mode + + ) : ( + // No session +
+
+

No sessions open

+

Click the + button to create a new session

+
+
+ )} +
- - {/* Permission Dialog */} - respondToPermission(requestId, true)} - onDeny={(requestId) => respondToPermission(requestId, false)} - />
); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/components/ChatInput.jsx b/frontend/src/components/ChatInput.jsx index f9f09c0..4dbd6b6 100644 --- a/frontend/src/components/ChatInput.jsx +++ b/frontend/src/components/ChatInput.jsx @@ -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
)} - {/* History search results dropdown */} - {showHistorySearch && historySearchResults.length > 0 && ( -
-
- - History search results ({historySearchResults.length}) -
- {historySearchResults.map((item, index) => ( - - ))} -
- )} -