refactor: improve shell performance, fix bugs on the git tab and promote login to a standalone component

Implement PTY session persistence with 30-minute timeout for shell reconnection. Sessions are now keyed by project path and session ID, preserving terminal state across UI disconnections with buffered output replay.
Refactor Shell component to use refs for stable prop access, removing unnecessary isActive prop and improving WebSocket connection lifecycle management. Replace conditional rendering with early returns in MainContent for better performance.
Add directory handling in git operations: support discarding, diffing, and viewing directories in untracked files. Prevent errors when staging or generating commit messages for directories.
Extract LoginModal into reusable component for Claude and Cursor CLI authentication. Add minimal mode to StandaloneShell for embedded use cases. Update Settings to use new LoginModal component.
Improve terminal dimensions handling by passing client-provided cols and rows to PTY spawn. Add comprehensive logging for session lifecycle and API operations.
This commit is contained in:
simos
2025-11-14 23:44:29 +00:00
parent 2815e206dc
commit 521fce32d0
8 changed files with 467 additions and 426 deletions

View File

@@ -164,6 +164,9 @@ async function setupProjectsWatcher() {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
// Single WebSocket server that handles both paths // Single WebSocket server that handles both paths
const wss = new WebSocketServer({ const wss = new WebSocketServer({
server, server,
@@ -397,9 +400,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
try { try {
const { projectName, sessionId } = req.params; const { projectName, sessionId } = req.params;
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
await deleteSession(projectName, sessionId); await deleteSession(projectName, sessionId);
console.log(`[API] Session ${sessionId} deleted successfully`);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -797,6 +803,8 @@ function handleChatConnection(ws) {
function handleShellConnection(ws) { function handleShellConnection(ws) {
console.log('🐚 Shell client connected'); console.log('🐚 Shell client connected');
let shellProcess = null; let shellProcess = null;
let ptySessionKey = null;
let outputBuffer = [];
ws.on('message', async (message) => { ws.on('message', async (message) => {
try { try {
@@ -804,7 +812,6 @@ function handleShellConnection(ws) {
console.log('📨 Shell message received:', data.type); console.log('📨 Shell message received:', data.type);
if (data.type === 'init') { if (data.type === 'init') {
// Initialize shell with project path and session info
const projectPath = data.projectPath || process.cwd(); const projectPath = data.projectPath || process.cwd();
const sessionId = data.sessionId; const sessionId = data.sessionId;
const hasSession = data.hasSession; const hasSession = data.hasSession;
@@ -812,6 +819,35 @@ function handleShellConnection(ws) {
const initialCommand = data.initialCommand; const initialCommand = data.initialCommand;
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell'; const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
ptySessionKey = `${projectPath}_${sessionId || 'default'}`;
const existingSession = ptySessionsMap.get(ptySessionKey);
if (existingSession) {
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
shellProcess = existingSession.pty;
clearTimeout(existingSession.timeoutId);
ws.send(JSON.stringify({
type: 'output',
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
}));
if (existingSession.buffer && existingSession.buffer.length > 0) {
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
existingSession.buffer.forEach(bufferedData => {
ws.send(JSON.stringify({
type: 'output',
data: bufferedData
}));
});
}
existingSession.ws = ws;
return;
}
console.log('[INFO] Starting shell in:', projectPath); console.log('[INFO] Starting shell in:', projectPath);
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session')); console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider); console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
@@ -885,10 +921,15 @@ function handleShellConnection(ws) {
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
// Use terminal dimensions from client if provided, otherwise use defaults
const termCols = data.cols || 80;
const termRows = data.rows || 24;
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
shellProcess = pty.spawn(shell, shellArgs, { shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color', name: 'xterm-256color',
cols: 80, cols: termCols,
rows: 24, rows: termRows,
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'), cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
env: { env: {
...process.env, ...process.env,
@@ -902,9 +943,28 @@ function handleShellConnection(ws) {
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid); console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
ptySessionsMap.set(ptySessionKey, {
pty: shellProcess,
ws: ws,
buffer: [],
timeoutId: null,
projectPath,
sessionId
});
// Handle data output // Handle data output
shellProcess.onData((data) => { shellProcess.onData((data) => {
if (ws.readyState === WebSocket.OPEN) { const session = ptySessionsMap.get(ptySessionKey);
if (!session) return;
if (session.buffer.length < 5000) {
session.buffer.push(data);
} else {
session.buffer.shift();
session.buffer.push(data);
}
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
let outputData = data; let outputData = data;
// Check for various URL opening patterns // Check for various URL opening patterns
@@ -928,7 +988,7 @@ function handleShellConnection(ws) {
console.log('[DEBUG] Detected URL for opening:', url); console.log('[DEBUG] Detected URL for opening:', url);
// Send URL opening message to client // Send URL opening message to client
ws.send(JSON.stringify({ session.ws.send(JSON.stringify({
type: 'url_open', type: 'url_open',
url: url url: url
})); }));
@@ -941,7 +1001,7 @@ function handleShellConnection(ws) {
}); });
// Send regular output // Send regular output
ws.send(JSON.stringify({ session.ws.send(JSON.stringify({
type: 'output', type: 'output',
data: outputData data: outputData
})); }));
@@ -951,12 +1011,17 @@ function handleShellConnection(ws) {
// Handle process exit // Handle process exit
shellProcess.onExit((exitCode) => { shellProcess.onExit((exitCode) => {
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal); console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
if (ws.readyState === WebSocket.OPEN) { const session = ptySessionsMap.get(ptySessionKey);
ws.send(JSON.stringify({ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({
type: 'output', type: 'output',
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n` data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
})); }));
} }
if (session && session.timeoutId) {
clearTimeout(session.timeoutId);
}
ptySessionsMap.delete(ptySessionKey);
shellProcess = null; shellProcess = null;
}); });
@@ -999,9 +1064,21 @@ function handleShellConnection(ws) {
ws.on('close', () => { ws.on('close', () => {
console.log('🔌 Shell client disconnected'); console.log('🔌 Shell client disconnected');
if (shellProcess && shellProcess.kill) {
console.log('🔴 Killing shell process:', shellProcess.pid); if (ptySessionKey) {
shellProcess.kill(); const session = ptySessionsMap.get(ptySessionKey);
if (session) {
console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
session.ws = null;
session.timeoutId = setTimeout(() => {
console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
if (session.pty && session.pty.kill) {
session.pty.kill();
}
ptySessionsMap.delete(ptySessionKey);
}, PTY_SESSION_TIMEOUT);
}
} }
}); });

View File

@@ -161,10 +161,18 @@ router.get('/diff', async (req, res) => {
let diff; let diff;
if (isUntracked) { if (isUntracked) {
// For untracked files, show the entire file content as additions // For untracked files, show the entire file content as additions
const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); const filePath = path.join(projectPath, file);
const lines = fileContent.split('\n'); const stats = await fs.stat(filePath);
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n'); if (stats.isDirectory()) {
// For directories, show a simple message
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
} else {
const fileContent = await fs.readFile(filePath, 'utf-8');
const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n');
}
} else if (isDeleted) { } else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions // For deleted files, show the entire file content from HEAD as deletions
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
@@ -222,7 +230,15 @@ router.get('/file-with-diff', async (req, res) => {
currentContent = headContent; // Show the deleted content in editor currentContent = headContent; // Show the deleted content in editor
} else { } else {
// Get current file content // Get current file content
currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// Cannot show content for directories
return res.status(400).json({ error: 'Cannot show diff for directories' });
}
currentContent = await fs.readFile(filePath, 'utf-8');
if (!isUntracked) { if (!isUntracked) {
// Get the old content from HEAD for tracked files // Get the old content from HEAD for tracked files
@@ -474,8 +490,14 @@ router.post('/generate-commit-message', async (req, res) => {
for (const file of files) { for (const file of files) {
try { try {
const filePath = path.join(projectPath, file); const filePath = path.join(projectPath, file);
const content = await fs.readFile(filePath, 'utf-8'); const stats = await fs.stat(filePath);
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
} else {
diffContext += `\n--- ${file} (new directory) ---\n`;
}
} catch (error) { } catch (error) {
console.error(`Error reading file ${file}:`, error); console.error(`Error reading file ${file}:`, error);
} }
@@ -976,10 +998,17 @@ router.post('/discard', async (req, res) => {
} }
const status = statusOutput.substring(0, 2); const status = statusOutput.substring(0, 2);
if (status === '??') { if (status === '??') {
// Untracked file - delete it // Untracked file or directory - delete it
await fs.unlink(path.join(projectPath, file)); const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
await fs.rm(filePath, { recursive: true, force: true });
} else {
await fs.unlink(filePath);
}
} else if (status.includes('M') || status.includes('D')) { } else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD // Modified or deleted file - restore from HEAD
await execAsync(`git restore "${file}"`, { cwd: projectPath }); await execAsync(`git restore "${file}"`, { cwd: projectPath });
@@ -1020,10 +1049,18 @@ router.post('/delete-untracked', async (req, res) => {
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' }); return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
} }
// Delete the untracked file // Delete the untracked file or directory
await fs.unlink(path.join(projectPath, file)); const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
if (stats.isDirectory()) {
// Use rm with recursive option for directories
await fs.rm(filePath, { recursive: true, force: true });
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
} else {
await fs.unlink(filePath);
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
}
} catch (error) { } catch (error) {
console.error('Git delete untracked error:', error); console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -0,0 +1,86 @@
import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell';
/**
* Reusable login modal component for Claude and Cursor CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'} props.provider - Which CLI provider to authenticate with
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
*/
function LoginModal({
isOpen,
onClose,
provider = 'claude',
project,
onComplete,
customCommand
}) {
if (!isOpen) return null;
const getCommand = () => {
if (customCommand) return customCommand;
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
default:
return 'claude setup-token --dangerously-skip-permissions';
}
};
const getTitle = () => {
switch (provider) {
case 'claude':
return 'Claude CLI Login';
case 'cursor':
return 'Cursor CLI Login';
default:
return 'CLI Login';
}
};
const handleComplete = (exitCode) => {
if (onComplete) {
onComplete(exitCode);
}
if (exitCode === 0) {
onClose();
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close login modal"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
</div>
</div>
</div>
);
}
export default LoginModal;

View File

@@ -496,20 +496,25 @@ function MainContent({
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}> {activeTab === 'files' && (
<FileTree selectedProject={selectedProject} /> <div className="h-full overflow-hidden">
</div> <FileTree selectedProject={selectedProject} />
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}> </div>
<StandaloneShell )}
project={selectedProject} {activeTab === 'shell' && (
session={selectedSession} <div className="h-full w-full overflow-hidden">
isActive={activeTab === 'shell'} <StandaloneShell
showHeader={false} project={selectedProject}
/> session={selectedSession}
</div> showHeader={false}
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}> />
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} /> </div>
</div> )}
{activeTab === 'git' && (
<div className="h-full overflow-hidden">
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div>
)}
{shouldShowTasksTab && ( {shouldShowTasksTab && (
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}> <div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
<div className="h-full flex flex-col overflow-hidden"> <div className="h-full flex flex-col overflow-hidden">

View File

@@ -5,10 +5,10 @@ import { Badge } from './ui/badge';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react'; import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import StandaloneShell from './StandaloneShell';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo'; import CursorLogo from './CursorLogo';
import CredentialsSettings from './CredentialsSettings'; import CredentialsSettings from './CredentialsSettings';
import LoginModal from './LoginModal';
function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) { function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const { isDarkMode, toggleDarkMode } = useTheme(); const { isDarkMode, toggleDarkMode } = useTheme();
@@ -441,8 +441,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const handleLoginComplete = (exitCode) => { const handleLoginComplete = (exitCode) => {
if (exitCode === 0) { if (exitCode === 0) {
// Login successful - could show a success message here // Login successful - could show a success message here
setSaveStatus('success');
} }
setShowLoginModal(false); // Modal will close itself via the LoginModal component
}; };
const saveSettings = () => { const saveSettings = () => {
@@ -2207,31 +2208,13 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div> </div>
{/* Login Modal */} {/* Login Modal */}
{showLoginModal && ( <LoginModal
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch"> isOpen={showLoginModal}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0"> onClose={() => setShowLoginModal(false)}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> provider={loginProvider}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> project={selectedProject}
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'} onComplete={handleLoginComplete}
</h3> />
<button
onClick={() => setShowLoginModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<StandaloneShell
project={selectedProject}
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
onComplete={handleLoginComplete}
showHeader={false}
/>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard'; import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
// CSS to remove xterm focus outline
const xtermStyles = ` const xtermStyles = `
.xterm .xterm-screen { .xterm .xterm-screen {
outline: none !important; outline: none !important;
@@ -18,7 +17,6 @@ const xtermStyles = `
} }
`; `;
// Inject styles
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style'); const styleSheet = document.createElement('style');
styleSheet.type = 'text/css'; styleSheet.type = 'text/css';
@@ -26,10 +24,7 @@ if (typeof document !== 'undefined') {
document.head.appendChild(styleSheet); document.head.appendChild(styleSheet);
} }
// Global store for shell sessions to persist across tab switches function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const shellSessions = new Map();
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
const terminalRef = useRef(null); const terminalRef = useRef(null);
const terminal = useRef(null); const terminal = useRef(null);
const fitAddon = useRef(null); const fitAddon = useRef(null);
@@ -40,177 +35,212 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
const [lastSessionId, setLastSessionId] = useState(null); const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
// Connect to shell function const selectedProjectRef = useRef(selectedProject);
const connectToShell = () => { const selectedSessionRef = useRef(selectedSession);
if (!isInitialized || isConnected || isConnecting) return; const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
setIsConnecting(true); const onProcessCompleteRef = useRef(onProcessComplete);
// Start the WebSocket connection
connectWebSocket();
};
// Disconnect from shell function useEffect(() => {
const disconnectFromShell = () => { selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
});
const connectWebSocket = useCallback(async () => {
if (isConnecting || isConnected) return;
try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
let wsUrl;
if (isPlatform) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
fitAddon.current.fit();
ws.current.send(JSON.stringify({
type: 'init',
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
cols: terminal.current.cols,
rows: terminal.current.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current
}));
}
}, 100);
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
let output = data.data;
if (isPlainShellRef.current && onProcessCompleteRef.current) {
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
if (cleanOutput.includes('Process exited with code 0')) {
onProcessCompleteRef.current(0);
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
if (exitCode !== 0) {
onProcessCompleteRef.current(exitCode);
}
}
}
if (terminal.current) {
terminal.current.write(output);
}
} else if (data.type === 'url_open') {
window.open(data.url, '_blank');
}
} catch (error) {
console.error('[Shell] Error handling WebSocket message:', error, event.data);
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
}, [isConnecting, isConnected]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
connectWebSocket();
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
const disconnectFromShell = useCallback(() => {
if (ws.current) { if (ws.current) {
ws.current.close(); ws.current.close();
ws.current = null; ws.current = null;
} }
// Clear terminal content completely
if (terminal.current) { if (terminal.current) {
terminal.current.clear(); terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home terminal.current.write('\x1b[2J\x1b[H');
} }
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
}; }, []);
const sessionDisplayName = useMemo(() => {
if (!selectedSession) return null;
return selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
}, [selectedSession]);
const sessionDisplayNameShort = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 30);
}, [sessionDisplayName]);
const sessionDisplayNameLong = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 50);
}, [sessionDisplayName]);
// Restart shell function
const restartShell = () => { const restartShell = () => {
setIsRestarting(true); setIsRestarting(true);
// Clear ALL session storage for this project to force fresh start
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
key.includes(selectedProject.name)
);
sessionKeys.forEach(key => shellSessions.delete(key));
// Close existing WebSocket
if (ws.current) { if (ws.current) {
ws.current.close(); ws.current.close();
ws.current = null; ws.current = null;
} }
// Clear and dispose existing terminal
if (terminal.current) { if (terminal.current) {
// Dispose terminal immediately without writing text
terminal.current.dispose(); terminal.current.dispose();
terminal.current = null; terminal.current = null;
fitAddon.current = null; fitAddon.current = null;
} }
// Reset states
setIsConnected(false); setIsConnected(false);
setIsInitialized(false); setIsInitialized(false);
// Force re-initialization after cleanup
setTimeout(() => { setTimeout(() => {
setIsRestarting(false); setIsRestarting(false);
}, 200); }, 200);
}; };
// Watch for session changes and restart shell
useEffect(() => { useEffect(() => {
const currentSessionId = selectedSession?.id || null; const currentSessionId = selectedSession?.id || null;
// Disconnect when session changes (user will need to manually reconnect)
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) { if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
// Disconnect from current shell
disconnectFromShell(); disconnectFromShell();
// Clear stored sessions for this project
const allKeys = Array.from(shellSessions.keys());
allKeys.forEach(key => {
if (key.includes(selectedProject.name)) {
shellSessions.delete(key);
}
});
} }
setLastSessionId(currentSessionId); setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized]); }, [selectedSession?.id, isInitialized, disconnectFromShell]);
// Initialize terminal when component mounts
useEffect(() => { useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
if (!terminalRef.current || !selectedProject || isRestarting) {
return; return;
} }
// Create session key for this project/session combination console.log('[Shell] Terminal initializing, mounting component');
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
// Check if we have an existing session
const existingSession = shellSessions.get(sessionKey);
if (existingSession && !terminal.current) {
try {
// Reuse existing terminal
terminal.current = existingSession.terminal;
fitAddon.current = existingSession.fitAddon;
ws.current = existingSession.ws;
setIsConnected(existingSession.isConnected);
// Reattach to DOM - dispose existing element first if needed
if (terminal.current.element && terminal.current.element.parentNode) {
terminal.current.element.parentNode.removeChild(terminal.current.element);
}
terminal.current.open(terminalRef.current);
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after reattaching
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
setIsInitialized(true);
return;
} catch (error) {
// Clear the broken session and continue to create a new one
shellSessions.delete(sessionKey);
terminal.current = null;
fitAddon.current = null;
ws.current = null;
}
}
if (terminal.current) {
return;
}
// Initialize new terminal
terminal.current = new Terminal({ terminal.current = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace', fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true, // Required for clipboard addon allowProposedApi: true,
allowTransparency: false, allowTransparency: false,
convertEol: true, convertEol: true,
scrollback: 10000, scrollback: 10000,
tabStopWidth: 4, tabStopWidth: 4,
// Enable full color support
windowsMode: false, windowsMode: false,
macOptionIsMeta: true, macOptionIsMeta: true,
macOptionClickForcesSelection: false, macOptionClickForcesSelection: false,
// Enhanced theme with full 16-color ANSI support + true colors
theme: { theme: {
// Basic colors
background: '#1e1e1e', background: '#1e1e1e',
foreground: '#d4d4d4', foreground: '#d4d4d4',
cursor: '#ffffff', cursor: '#ffffff',
cursorAccent: '#1e1e1e', cursorAccent: '#1e1e1e',
selection: '#264f78', selection: '#264f78',
selectionForeground: '#ffffff', selectionForeground: '#ffffff',
// Standard ANSI colors (0-7)
black: '#000000', black: '#000000',
red: '#cd3131', red: '#cd3131',
green: '#0dbc79', green: '#0dbc79',
@@ -219,8 +249,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
magenta: '#bc3fbc', magenta: '#bc3fbc',
cyan: '#11a8cd', cyan: '#11a8cd',
white: '#e5e5e5', white: '#e5e5e5',
// Bright ANSI colors (8-15)
brightBlack: '#666666', brightBlack: '#666666',
brightRed: '#f14c4c', brightRed: '#f14c4c',
brightGreen: '#23d18b', brightGreen: '#23d18b',
@@ -229,10 +257,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
brightMagenta: '#d670d6', brightMagenta: '#d670d6',
brightCyan: '#29b8db', brightCyan: '#29b8db',
brightWhite: '#ffffff', brightWhite: '#ffffff',
// Extended colors for better Claude output
extendedAnsi: [ extendedAnsi: [
// 16-color palette extension for 256-color support
'#000000', '#800000', '#008000', '#808000', '#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0', '#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00', '#808080', '#ff0000', '#00ff00', '#ffff00',
@@ -247,30 +272,21 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
terminal.current.loadAddon(fitAddon.current); terminal.current.loadAddon(fitAddon.current);
terminal.current.loadAddon(clipboardAddon); terminal.current.loadAddon(clipboardAddon);
try { try {
terminal.current.loadAddon(webglAddon); terminal.current.loadAddon(webglAddon);
} catch (error) { } catch (error) {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
} }
terminal.current.open(terminalRef.current); terminal.current.open(terminalRef.current);
// Wait for terminal to be fully rendered, then fit
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 50);
// Add keyboard shortcuts for copy/paste
terminal.current.attachCustomKeyEventHandler((event) => { terminal.current.attachCustomKeyEventHandler((event) => {
// Ctrl+C or Cmd+C for copy (when text is selected)
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) { if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
document.execCommand('copy'); document.execCommand('copy');
return false; return false;
} }
// Ctrl+V or Cmd+V for paste
if ((event.ctrlKey || event.metaKey) && event.key === 'v') { if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
navigator.clipboard.readText().then(text => { navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) { if (ws.current && ws.current.readyState === WebSocket.OPEN) {
@@ -279,20 +295,16 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
data: text data: text
})); }));
} }
}).catch(err => { }).catch(() => {});
// Failed to read clipboard
});
return false; return false;
} }
return true; return true;
}); });
// Ensure terminal takes full space and notify backend of size
setTimeout(() => { setTimeout(() => {
if (fitAddon.current) { if (fitAddon.current) {
fitAddon.current.fit(); fitAddon.current.fit();
// Send terminal size to backend after fitting
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ ws.current.send(JSON.stringify({
type: 'resize', type: 'resize',
@@ -302,10 +314,8 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
} }
} }
}, 100); }, 100);
setIsInitialized(true);
// Handle terminal input setIsInitialized(true);
terminal.current.onData((data) => { terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) { if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ ws.current.send(JSON.stringify({
@@ -315,12 +325,10 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
} }
}); });
// Add resize observer to handle container size changes
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) { if (fitAddon.current && terminal.current) {
setTimeout(() => { setTimeout(() => {
fitAddon.current.fit(); fitAddon.current.fit();
// Send updated terminal size to backend after resize
if (ws.current && ws.current.readyState === WebSocket.OPEN) { if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ ws.current.send(JSON.stringify({
type: 'resize', type: 'resize',
@@ -337,178 +345,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
} }
return () => { return () => {
console.log('[Shell] Terminal cleanup, unmounting component');
resizeObserver.disconnect(); resizeObserver.disconnect();
// Store session for reuse instead of disposing if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
if (terminal.current && selectedProject) { ws.current.close();
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`; }
ws.current = null;
try {
shellSessions.set(sessionKey, { if (terminal.current) {
terminal: terminal.current, terminal.current.dispose();
fitAddon: fitAddon.current, terminal.current = null;
ws: ws.current,
isConnected: isConnected
});
} catch (error) {
}
} }
}; };
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]); }, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
// Fit terminal when tab becomes active
useEffect(() => { useEffect(() => {
if (!isActive || !isInitialized) return; if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
connectToShell();
// Fit terminal when tab becomes active and notify backend }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after tab activation
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
}, [isActive, isInitialized]);
// WebSocket connection function (called manually)
const connectWebSocket = async () => {
if (isConnecting || isConnected) return;
try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
// Construct WebSocket URL
let wsUrl;
if (isPlatform) {
// Platform mode: Use same domain as the page (goes through proxy)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
// OSS mode: Connect to same host:port that served the page
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
// Wait for terminal to be ready, then fit and send dimensions
setTimeout(() => {
if (fitAddon.current && terminal.current) {
// Force a fit to ensure proper dimensions
fitAddon.current.fit();
// Wait a bit more for fit to complete, then send dimensions
setTimeout(() => {
const initPayload = {
type: 'init',
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: isPlainShell ? null : selectedSession?.id,
hasSession: isPlainShell ? false : !!selectedSession,
provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'),
cols: terminal.current.cols,
rows: terminal.current.rows,
initialCommand: initialCommand,
isPlainShell: isPlainShell
};
console.log('Shell init payload:', initPayload);
ws.current.send(JSON.stringify(initPayload));
// Also send resize message immediately after init
setTimeout(() => {
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 100);
}, 50);
}
}, 200);
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
// Check for URLs in the output and make them clickable
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
let output = data.data;
// Find URLs in the text (excluding ANSI escape sequences)
const urls = [];
let match;
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
urls.push(match[1]);
}
if (isPlainShell && onProcessComplete) {
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes
if (cleanOutput.includes('Process exited with code 0')) {
onProcessComplete(0); // Success
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
if (exitCode !== 0) {
onProcessComplete(exitCode); // Error
}
}
}
// If URLs found, log them for potential opening
terminal.current.write(output);
} else if (data.type === 'url_open') {
// Handle explicit URL opening requests from server
window.open(data.url, '_blank');
}
} catch (error) {
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
// Clear terminal content when connection closes
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
}
// Don't auto-reconnect anymore - user must manually connect
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
};
if (!selectedProject) { if (!selectedProject) {
return ( return (
@@ -526,23 +381,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
); );
} }
if (minimal) {
return (
<div className="h-full w-full bg-gray-900">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
</div>
);
}
return ( return (
<div className="h-full flex flex-col bg-gray-900 w-full"> <div className="h-full flex flex-col bg-gray-900 w-full">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2"> <div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} /> <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (() => { {selectedSession && (
const displaySessionName = selectedSession.__provider === 'cursor' <span className="text-xs text-blue-300">
? (selectedSession.name || 'Untitled Session') ({sessionDisplayNameShort}...)
: (selectedSession.summary || 'New Session'); </span>
return ( )}
<span className="text-xs text-blue-300">
({displaySessionName.slice(0, 30)}...)
</span>
);
})()}
{!selectedSession && ( {!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span> <span className="text-xs text-gray-400">(New Session)</span>
)} )}
@@ -566,7 +423,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
<span>Disconnect</span> <span>Disconnect</span>
</button> </button>
)} )}
<button <button
onClick={restartShell} onClick={restartShell}
disabled={isRestarting || isConnected} disabled={isRestarting || isConnected}
@@ -582,18 +439,15 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
</div> </div>
</div> </div>
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative"> <div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} /> <div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{/* Loading state */}
{!isInitialized && ( {!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div> <div className="text-white">Loading terminal...</div>
</div> </div>
)} )}
{/* Connect button when not connected */}
{isInitialized && !isConnected && !isConnecting && ( {isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full"> <div className="text-center max-w-sm w-full">
@@ -608,23 +462,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
<span>Continue in Shell</span> <span>Continue in Shell</span>
</button> </button>
<p className="text-gray-400 text-sm mt-3 px-2"> <p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ? {isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` : `Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
selectedSession ? selectedSession ?
(() => { `Resume session: ${sessionDisplayNameLong}...` :
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
})() :
'Start a new Claude session' 'Start a new Claude session'
} }
</p> </p>
</div> </div>
</div> </div>
)} )}
{/* Connecting state */}
{isConnecting && ( {isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full"> <div className="text-center max-w-sm w-full">
@@ -633,7 +481,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
<span className="text-base font-medium">Connecting to shell...</span> <span className="text-base font-medium">Connecting to shell...</span>
</div> </div>
<p className="text-gray-400 text-sm mt-3 px-2"> <p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ? {isPlainShell ?
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` : `Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
`Starting Claude CLI in ${selectedProject.displayName}` `Starting Claude CLI in ${selectedProject.displayName}`
} }

View File

@@ -303,19 +303,25 @@ function Sidebar({
} }
try { try {
console.log('[Sidebar] Deleting session:', { projectName, sessionId });
const response = await api.deleteSession(projectName, sessionId); const response = await api.deleteSession(projectName, sessionId);
console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status });
if (response.ok) { if (response.ok) {
console.log('[Sidebar] Session deleted successfully, calling callback');
// Call parent callback if provided // Call parent callback if provided
if (onSessionDelete) { if (onSessionDelete) {
onSessionDelete(sessionId); onSessionDelete(sessionId);
} else {
console.warn('[Sidebar] No onSessionDelete callback provided');
} }
} else { } else {
console.error('Failed to delete session'); const errorText = await response.text();
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
alert('Failed to delete session. Please try again.'); alert('Failed to delete session. Please try again.');
} }
} catch (error) { } catch (error) {
console.error('Error deleting session:', error); console.error('[Sidebar] Error deleting session:', error);
alert('Error deleting session. Please try again.'); alert('Error deleting session. Please try again.');
} }
}; };

View File

@@ -1,14 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useCallback } from 'react';
import Shell from './Shell.jsx'; import Shell from './Shell.jsx';
/** /**
* Generic Shell wrapper that can be used in tabs, modals, and other contexts. * Generic Shell wrapper that can be used in tabs, modals, and other contexts.
* Provides a flexible API for both standalone and session-based usage. * Provides a flexible API for both standalone and session-based usage.
* *
* @param {Object} project - Project object with name, fullPath/path, displayName * @param {Object} project - Project object with name, fullPath/path, displayName
* @param {Object} session - Session object (optional, for tab usage) * @param {Object} session - Session object (optional, for tab usage)
* @param {string} command - Initial command to run (optional) * @param {string} command - Initial command to run (optional)
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect) * @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true) * @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
* @param {function} onComplete - Callback when process completes (receives exitCode) * @param {function} onComplete - Callback when process completes (receives exitCode)
@@ -17,33 +16,32 @@ import Shell from './Shell.jsx';
* @param {string} className - Additional CSS classes * @param {string} className - Additional CSS classes
* @param {boolean} showHeader - Whether to show custom header (default: true) * @param {boolean} showHeader - Whether to show custom header (default: true)
* @param {boolean} compact - Use compact layout (default: false) * @param {boolean} compact - Use compact layout (default: false)
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
*/ */
function StandaloneShell({ function StandaloneShell({
project, project,
session = null, session = null,
command = null, command = null,
isActive = true, isPlainShell = null,
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
autoConnect = true, autoConnect = true,
onComplete = null, onComplete = null,
onClose = null, onClose = null,
title = null, title = null,
className = "", className = "",
showHeader = true, showHeader = true,
compact = false compact = false,
minimal = false
}) { }) {
const [isCompleted, setIsCompleted] = useState(false); const [isCompleted, setIsCompleted] = useState(false);
// Auto-detect isPlainShell based on props
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null); const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
// Handle process completion const handleProcessComplete = useCallback((exitCode) => {
const handleProcessComplete = (exitCode) => {
setIsCompleted(true); setIsCompleted(true);
if (onComplete) { if (onComplete) {
onComplete(exitCode); onComplete(exitCode);
} }
}; }, [onComplete]);
if (!project) { if (!project) {
return ( return (
@@ -62,9 +60,9 @@ function StandaloneShell({
} }
return ( return (
<div className={`h-full flex flex-col ${className}`}> <div className={`h-full w-full flex flex-col ${className}`}>
{/* Optional custom header */} {/* Optional custom header */}
{showHeader && title && ( {!minimal && showHeader && title && (
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2"> <div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -89,14 +87,15 @@ function StandaloneShell({
)} )}
{/* Shell component wrapper */} {/* Shell component wrapper */}
<div className="flex-1"> <div className="flex-1 w-full min-h-0">
<Shell <Shell
selectedProject={project} selectedProject={project}
selectedSession={session} selectedSession={session}
isActive={isActive}
initialCommand={command} initialCommand={command}
isPlainShell={shouldUsePlainShell} isPlainShell={shouldUsePlainShell}
onProcessComplete={handleProcessComplete} onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}
/> />
</div> </div>
</div> </div>