5 Commits

Author SHA1 Message Date
simos
6219c273a2 small fix 2025-11-17 08:48:23 +01:00
simos
33834d808b feature:show auth status on settings 2025-11-17 08:48:15 +01:00
simos
abe8cd46a2 fixes 2025-11-16 14:52:45 +01:00
simos
521fce32d0 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.
2025-11-14 23:44:29 +00:00
simos
2815e206dc refactor: Remove unecessary websocket calls for taskmaster 2025-11-14 16:31:33 +01:00
26 changed files with 834 additions and 650 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -70,6 +70,7 @@ import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -164,6 +165,9 @@ async function setupProjectsWatcher() {
const app = express();
const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
server,
@@ -247,6 +251,9 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
// CLI Authentication API Routes (protected)
app.use('/api/cli', authenticateToken, cliAuthRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -397,9 +404,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
await deleteSession(projectName, sessionId);
console.log(`[API] Session ${sessionId} deleted successfully`);
res.json({ success: true });
} catch (error) {
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
res.status(500).json({ error: error.message });
}
});
@@ -797,6 +807,8 @@ function handleChatConnection(ws) {
function handleShellConnection(ws) {
console.log('🐚 Shell client connected');
let shellProcess = null;
let ptySessionKey = null;
let outputBuffer = [];
ws.on('message', async (message) => {
try {
@@ -804,7 +816,6 @@ function handleShellConnection(ws) {
console.log('📨 Shell message received:', data.type);
if (data.type === 'init') {
// Initialize shell with project path and session info
const projectPath = data.projectPath || process.cwd();
const sessionId = data.sessionId;
const hasSession = data.hasSession;
@@ -812,6 +823,35 @@ function handleShellConnection(ws) {
const initialCommand = data.initialCommand;
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('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
@@ -885,10 +925,15 @@ function handleShellConnection(ws) {
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
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, {
name: 'xterm-256color',
cols: 80,
rows: 24,
cols: termCols,
rows: termRows,
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
env: {
...process.env,
@@ -902,9 +947,28 @@ function handleShellConnection(ws) {
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
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;
// Check for various URL opening patterns
@@ -928,7 +992,7 @@ function handleShellConnection(ws) {
console.log('[DEBUG] Detected URL for opening:', url);
// Send URL opening message to client
ws.send(JSON.stringify({
session.ws.send(JSON.stringify({
type: 'url_open',
url: url
}));
@@ -941,7 +1005,7 @@ function handleShellConnection(ws) {
});
// Send regular output
ws.send(JSON.stringify({
session.ws.send(JSON.stringify({
type: 'output',
data: outputData
}));
@@ -951,12 +1015,17 @@ function handleShellConnection(ws) {
// Handle process exit
shellProcess.onExit((exitCode) => {
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
const session = ptySessionsMap.get(ptySessionKey);
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({
type: 'output',
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;
});
@@ -999,9 +1068,21 @@ function handleShellConnection(ws) {
ws.on('close', () => {
console.log('🔌 Shell client disconnected');
if (shellProcess && shellProcess.kill) {
console.log('🔴 Killing shell process:', shellProcess.pid);
shellProcess.kill();
if (ptySessionKey) {
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);
}
}
});

179
server/routes/cli-auth.js Normal file
View File

@@ -0,0 +1,179 @@
import express from 'express';
import { spawn } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const router = express.Router();
router.get('/claude/status', async (req, res) => {
try {
const credentialsResult = await checkClaudeCredentials();
if (credentialsResult.authenticated) {
return res.json({
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: 'credentials_file'
});
}
return res.json({
authenticated: false,
email: null,
error: credentialsResult.error || 'Not authenticated'
});
} catch (error) {
console.error('Error checking Claude auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/cursor/status', async (req, res) => {
try {
const result = await checkCursorStatus();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Cursor auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
async function checkClaudeCredentials() {
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
if (creds.accessToken) {
const isExpired = creds.expiresAt && Date.now() >= creds.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null
};
}
}
return {
authenticated: false,
email: null
};
} catch (error) {
return {
authenticated: false,
email: null
};
}
}
function checkCursorStatus() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch (err) {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({
authenticated: true,
email: emailMatch[1],
output: stdout
});
} else if (stdout.includes('Logged in')) {
resolve({
authenticated: true,
email: 'Logged in',
output: stdout
});
} else {
resolve({
authenticated: false,
email: null,
error: 'Not logged in'
});
}
} else {
resolve({
authenticated: false,
email: null,
error: stderr || 'Not logged in'
});
}
});
childProcess.on('error', (err) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}
export default router;

View File

@@ -161,10 +161,18 @@ router.get('/diff', async (req, res) => {
let diff;
if (isUntracked) {
// 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 stats = await fs.stat(filePath);
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) {
// For deleted files, show the entire file content from HEAD as deletions
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
} else {
// 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) {
// 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) {
try {
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
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) {
console.error(`Error reading file ${file}:`, error);
}
@@ -978,8 +1000,15 @@ router.post('/discard', async (req, res) => {
const status = statusOutput.substring(0, 2);
if (status === '??') {
// Untracked file - delete it
await fs.unlink(path.join(projectPath, file));
// Untracked file or directory - delete it
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')) {
// Modified or deleted file - restore from HEAD
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.' });
}
// Delete the untracked file
await fs.unlink(path.join(projectPath, file));
// Delete the untracked file or directory
const filePath = path.join(projectPath, file);
const stats = await fs.stat(filePath);
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) {
console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message });

View File

@@ -331,15 +331,6 @@ router.get('/detect/:projectName', async (req, res) => {
timestamp: new Date().toISOString()
};
// Broadcast TaskMaster project update via WebSocket
if (req.app.locals.wss) {
broadcastTaskMasterProjectUpdate(
req.app.locals.wss,
projectName,
taskMasterResult
);
}
res.json(responseData);
} catch (error) {

View File

@@ -1,151 +0,0 @@
# Slash Command Execution Fix - Progress Report
## Issue
Slash commands weren't executing when selected from the command menu. After typing a command like `/tm:list` and selecting it from the menu, nothing would happen - the page would stay on "Choose Your AI Assistant" screen.
## Root Cause
The `handleCustomCommand` function was trying to call `handleSubmit` via a ref, but the ref wasn't being set properly. Originally attempted to set the ref inside `handleSubmit` itself, which meant it was only set AFTER the first submit - too late for command execution.
## Solution Implemented
1. Converted `handleSubmit` to use `useCallback` with proper dependencies
2. Added a `useEffect` hook that runs after `handleSubmit` is defined to store it in `handleSubmitRef`
3. Now `handleCustomCommand` can access `handleSubmit` via the ref and call it with a fake event
## Code Changes
### File: src/components/ChatInterface.jsx
**Added ref declaration (around line 1534):**
```javascript
// Ref to store handleSubmit so we can call it from handleCustomCommand
const handleSubmitRef = useRef(null);
```
**Modified handleCustomCommand (around line 1555):**
```javascript
// Set the input to the command content
setInput(content);
// Wait for state to update, then directly call handleSubmit
setTimeout(() => {
if (handleSubmitRef.current) {
// Create a fake event to pass to handleSubmit
const fakeEvent = { preventDefault: () => {} };
handleSubmitRef.current(fakeEvent);
}
}, 50);
```
**Converted handleSubmit to useCallback (line 3292):**
```javascript
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return;
// ... rest of function
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
```
**Added useEffect to store ref (line 3437):**
```javascript
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
```
## Fixed Issues
### 1. Commands Button Visibility ✅ FIXED
- **Problem**: Button was not showing in active chat sessions with provider selected
- **Root Cause**: Button was positioned at `right-14 sm:right-16` which overlapped with the clear button at `sm:right-28`
- **Solution**: Changed button position to `right-14 sm:right-36` to place it left of the clear button
- **File**: src/components/ChatInterface.jsx:4255
- **Status**: Fixed in build dist/assets/index-CWRjcZ7A.js
### 2. Slash Command Menu Positioning ✅ FIXED
- **Problem**: Mobile positioning was inconsistent - used wrong ref for bottom calculation
- **Root Cause**: Position calculation used `inputContainerRef` (permission mode selector) instead of `textareaRef` (actual input)
- **Solution**:
- Changed bottom calculation to use `textareaRef` instead of `inputContainerRef`
- Updated formula: `window.innerHeight - textareaRef.getBoundingClientRect().top + 8`
- Removed extra `+ 8` in CommandMenu.jsx since spacing is already in the calculation
- Added explicit `maxHeight: '300px'` to desktop positioning for consistency
- Mobile maxHeight now uses `min(50vh, 300px)` for better consistency
- **Files Modified**:
- src/components/ChatInterface.jsx:4132-4134 - Fixed bottom position calculation
- src/components/CommandMenu.jsx:30-46 - Improved positioning logic and max heights
## Related Issues Found (Not Fixed Yet)
### 3. Service Worker Caching Issue
- After building, the service worker caches old build files
- Requires manual unregistration of service worker on first load after build
- Causes 404 errors for old asset filenames (e.g., index-n_2V3_vw.js when new build has index-Wp3pq386.js)
- Need to implement proper cache busting or service worker update strategy
### 4. Chat Screen Jumping
- Screen jumps/scrolls when Task Master widget appears/disappears
- Likely due to layout shifts from the task widget
## Testing Status
- ✅ Slash command execution fix implemented and built
- ✅ Commands button visibility fix implemented and built
- ⏳ Not yet tested end-to-end due to service worker caching issues requiring manual cache clearing
- Need to test:
1. Verify commands button is now visible to the left of clear button
2. Click commands button to open menu
3. Type `/tm:list` in chat input
4. Select command from menu
5. Verify command content loads and sends to Claude
6. Verify session is created if none exists
## Next Steps
1. Test the slash command button visibility fix
2. Test the slash command execution fix end-to-end
3. Fix service worker caching to enable easier testing
4. Fix chat screen jumping issue
## Build Info
- Latest build: dist/assets/index-C5zDTo8x.js (657.55 kB)
- Commands button positioned at `right-14 sm:right-36` (mobile/desktop)
- Menu positioning uses `textareaRef` for accurate placement
- Mobile menu: `bottom` calculated from textarea top + 8px spacing
- Desktop menu: `top` calculated with 316px offset, max 300px height
- Server running on port 3001
- Using Claude Agents SDK for Claude integration
## Implementation Details
### Mobile Positioning
```javascript
// ChatInterface.jsx - Position calculation
bottom: textareaRef.current
? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8
: 90
// CommandMenu.jsx - Mobile layout
{
position: 'fixed',
bottom: `${inputBottom}px`,
left: '16px',
right: '16px',
maxHeight: 'min(50vh, 300px)'
}
```
### Desktop Positioning
```javascript
// ChatInterface.jsx - Position calculation
top: textareaRef.current
? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316)
: 0
// CommandMenu.jsx - Desktop layout
{
position: 'fixed',
top: `${calculatedTop}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxHeight: '300px'
}
```

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>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
{activeTab === 'files' && (
<div className="h-full overflow-hidden">
<FileTree selectedProject={selectedProject} />
</div>
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
)}
{activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden">
<StandaloneShell
project={selectedProject}
session={selectedSession}
isActive={activeTab === 'shell'}
showHeader={false}
/>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
)}
{activeTab === 'git' && (
<div className="h-full overflow-hidden">
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div>
)}
{shouldShowTasksTab && (
<div className={`h-full ${activeTab === 'tasks' ? 'block' : '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 { useTheme } from '../contexts/ThemeContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import StandaloneShell from './StandaloneShell';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CredentialsSettings from './CredentialsSettings';
import LoginModal from './LoginModal';
function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const { isDarkMode, toggleDarkMode } = useTheme();
@@ -81,10 +81,23 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
const [cursorMcpServers, setCursorMcpServers] = useState([]);
// Login modal states
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState(''); // 'claude' or 'cursor'
const [loginProvider, setLoginProvider] = useState('');
const [selectedProject, setSelectedProject] = useState(null);
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [cursorAuthStatus, setCursorAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
// Common tool patterns for Claude
const commonTools = [
'Bash(git log:*)',
@@ -344,7 +357,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
useEffect(() => {
if (isOpen) {
loadSettings();
// Set the active tab when the modal opens
checkClaudeAuthStatus();
checkCursorAuthStatus();
setActiveTab(initialTab);
}
}, [isOpen, initialTab]);
@@ -425,7 +439,79 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
}
};
// Login handlers
const checkClaudeAuthStatus = async () => {
try {
const token = localStorage.getItem('auth-token');
const response = await fetch('/api/cli/claude/status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setClaudeAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setClaudeAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Claude auth status:', error);
setClaudeAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkCursorAuthStatus = async () => {
try {
const token = localStorage.getItem('auth-token');
const response = await fetch('/api/cli/cursor/status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setCursorAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Cursor auth status:', error);
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const handleClaudeLogin = () => {
setLoginProvider('claude');
setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
@@ -440,9 +526,14 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
// Login successful - could show a success message here
setSaveStatus('success');
if (loginProvider === 'claude') {
checkClaudeAuthStatus();
} else if (loginProvider === 'cursor') {
checkCursorAuthStatus();
}
}
setShowLoginModal(false);
};
const saveSettings = () => {
@@ -1029,7 +1120,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div>
</div>
{/* Claude Login */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<LogIn className="w-5 h-5 text-blue-500" />
@@ -1038,13 +1128,39 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</h3>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
{claudeAuthStatus.loading ? (
<span className="text-sm text-blue-700 dark:text-blue-300">
Checking authentication...
</span>
) : claudeAuthStatus.authenticated ? (
<div className="flex items-center gap-2">
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Logged in
</Badge>
{claudeAuthStatus.email && (
<span className="text-sm text-blue-700 dark:text-blue-300">
as {claudeAuthStatus.email}
</span>
)}
</div>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Not authenticated
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-blue-900 dark:text-blue-100">
Claude CLI Login
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
Sign in to your Claude account to enable AI features
{claudeAuthStatus.authenticated
? 'Re-authenticate or switch accounts'
: 'Sign in to your Claude account to enable AI features'}
</div>
</div>
<Button
@@ -1058,6 +1174,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div>
</div>
</div>
</div>
{/* Allowed Tools */}
<div className="space-y-4">
@@ -1789,7 +1906,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div>
</div>
{/* Cursor Login */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<LogIn className="w-5 h-5 text-purple-500" />
@@ -1798,13 +1914,39 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</h3>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
{cursorAuthStatus.loading ? (
<span className="text-sm text-purple-700 dark:text-purple-300">
Checking authentication...
</span>
) : cursorAuthStatus.authenticated ? (
<div className="flex items-center gap-2">
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Logged in
</Badge>
{cursorAuthStatus.email && (
<span className="text-sm text-purple-700 dark:text-purple-300">
as {cursorAuthStatus.email}
</span>
)}
</div>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Not authenticated
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-purple-900 dark:text-purple-100">
Cursor CLI Login
</div>
<div className="text-sm text-purple-700 dark:text-purple-300">
Sign in to your Cursor account to enable AI features
{cursorAuthStatus.authenticated
? 'Re-authenticate or switch accounts'
: 'Sign in to your Cursor account to enable AI features'}
</div>
</div>
<Button
@@ -1818,6 +1960,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div>
</div>
</div>
</div>
{/* Allowed Shell Commands */}
<div className="space-y-4">
@@ -2207,32 +2350,14 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
</div>
{/* Login Modal */}
{showLoginModal && (
<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">
<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">
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
</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
<LoginModal
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
provider={loginProvider}
project={selectedProject}
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
onComplete={handleLoginComplete}
showHeader={false}
/>
</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 { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import '@xterm/xterm/css/xterm.css';
// CSS to remove xterm focus outline
const xtermStyles = `
.xterm .xterm-screen {
outline: none !important;
@@ -18,7 +17,6 @@ const xtermStyles = `
}
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
@@ -26,10 +24,7 @@ if (typeof document !== 'undefined') {
document.head.appendChild(styleSheet);
}
// Global store for shell sessions to persist across tab switches
const shellSessions = new Map();
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
@@ -40,177 +35,212 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
// Connect to shell function
const connectToShell = () => {
if (!isInitialized || isConnected || isConnecting) return;
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
setIsConnecting(true);
useEffect(() => {
selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
});
// Start the WebSocket connection
connectWebSocket();
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);
};
// Disconnect from shell function
const disconnectFromShell = () => {
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) {
ws.current.close();
ws.current = null;
}
// Clear terminal content completely
if (terminal.current) {
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);
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 = () => {
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) {
ws.current.close();
ws.current = null;
}
// Clear and dispose existing terminal
if (terminal.current) {
// Dispose terminal immediately without writing text
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
// Reset states
setIsConnected(false);
setIsInitialized(false);
// Force re-initialization after cleanup
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
// Watch for session changes and restart shell
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
// Disconnect when session changes (user will need to manually reconnect)
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
// Disconnect from current shell
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);
}, [selectedSession?.id, isInitialized]);
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
// Initialize terminal when component mounts
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting) {
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
return;
}
// Create session key for this project/session combination
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
console.log('[Shell] Terminal initializing, mounting component');
// 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({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true, // Required for clipboard addon
allowProposedApi: true,
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
// Enable full color support
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
// Enhanced theme with full 16-color ANSI support + true colors
theme: {
// Basic colors
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
// Standard ANSI colors (0-7)
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
@@ -219,8 +249,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
// Bright ANSI colors (8-15)
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
@@ -229,10 +257,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
// Extended colors for better Claude output
extendedAnsi: [
// 16-color palette extension for 256-color support
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
@@ -251,26 +276,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
try {
terminal.current.loadAddon(webglAddon);
} catch (error) {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
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) => {
// Ctrl+C or Cmd+C for copy (when text is selected)
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
document.execCommand('copy');
return false;
}
// Ctrl+V or Cmd+V for paste
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
@@ -279,20 +295,16 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
data: text
}));
}
}).catch(err => {
// Failed to read clipboard
});
}).catch(() => {});
return false;
}
return true;
});
// Ensure terminal takes full space and notify backend of size
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after fitting
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
@@ -304,8 +316,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
}, 100);
setIsInitialized(true);
// Handle terminal input
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
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(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
// Send updated terminal size to backend after resize
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
@@ -337,178 +345,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
}
return () => {
console.log('[Shell] Terminal cleanup, unmounting component');
resizeObserver.disconnect();
// Store session for reuse instead of disposing
if (terminal.current && selectedProject) {
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
try {
shellSessions.set(sessionKey, {
terminal: terminal.current,
fitAddon: fitAddon.current,
ws: ws.current,
isConnected: isConnected
});
} catch (error) {
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
ws.current.close();
}
}
};
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
ws.current = null;
// Fit terminal when tab becomes active
useEffect(() => {
if (!isActive || !isInitialized) return;
// Fit terminal when tab becomes active and notify backend
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);
terminal.current.dispose();
terminal.current = null;
}
};
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
connectToShell();
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
if (!selectedProject) {
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 (
<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 items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return (
{selectedSession && (
<span className="text-xs text-blue-300">
({displaySessionName.slice(0, 30)}...)
({sessionDisplayNameShort}...)
</span>
);
})()}
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
@@ -582,18 +439,15 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
</div>
</div>
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{/* Loading state */}
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
</div>
)}
{/* Connect button when not connected */}
{isInitialized && !isConnected && !isConnecting && (
<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">
@@ -611,12 +465,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
{isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
selectedSession ?
(() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
})() :
`Resume session: ${sessionDisplayNameLong}...` :
'Start a new Claude session'
}
</p>
@@ -624,7 +473,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
</div>
)}
{/* Connecting state */}
{isConnecting && (
<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">

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import Shell from './Shell.jsx';
/**
@@ -8,7 +8,6 @@ import Shell from './Shell.jsx';
* @param {Object} project - Project object with name, fullPath/path, displayName
* @param {Object} session - Session object (optional, for tab usage)
* @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} autoConnect - Whether to auto-connect when mounted (default: true)
* @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 {boolean} showHeader - Whether to show custom header (default: true)
* @param {boolean} compact - Use compact layout (default: false)
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
*/
function StandaloneShell({
project,
session = null,
command = null,
isActive = true,
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
isPlainShell = null,
autoConnect = true,
onComplete = null,
onClose = null,
title = null,
className = "",
showHeader = true,
compact = false
compact = false,
minimal = false
}) {
const [isCompleted, setIsCompleted] = useState(false);
// Auto-detect isPlainShell based on props
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
// Handle process completion
const handleProcessComplete = (exitCode) => {
const handleProcessComplete = useCallback((exitCode) => {
setIsCompleted(true);
if (onComplete) {
onComplete(exitCode);
}
};
}, [onComplete]);
if (!project) {
return (
@@ -62,9 +60,9 @@ function StandaloneShell({
}
return (
<div className={`h-full flex flex-col ${className}`}>
<div className={`h-full w-full flex flex-col ${className}`}>
{/* 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 items-center justify-between">
<div className="flex items-center space-x-2">
@@ -89,14 +87,15 @@ function StandaloneShell({
)}
{/* Shell component wrapper */}
<div className="flex-1">
<div className="flex-1 w-full min-h-0">
<Shell
selectedProject={project}
selectedSession={session}
isActive={isActive}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}
/>
</div>
</div>

View File

@@ -134,35 +134,13 @@ export const TaskMasterProvider = ({ children }) => {
try {
setCurrentProjectState(project);
// Clear previous project's data immediately when switching projects
setTasks([]);
setNextTask(null);
setProjectTaskMaster(null); // Clear previous TaskMaster data
// Try to fetch fresh TaskMaster detection data for the project
if (project?.name) {
try {
const response = await api.get(`/taskmaster/detect/${encodeURIComponent(project.name)}`);
if (response.ok) {
const detectionData = await response.json();
setProjectTaskMaster(detectionData.taskmaster);
} else {
// If individual detection fails, fall back to project data from /api/projects
console.warn('Individual TaskMaster detection failed, using project data:', response.status);
setProjectTaskMaster(project.taskmaster || null);
}
} catch (detectionError) {
// If individual detection fails, fall back to project data from /api/projects
console.warn('TaskMaster detection error, using project data:', detectionError.message);
setProjectTaskMaster(project.taskmaster || null);
}
} else {
setProjectTaskMaster(null);
}
setProjectTaskMaster(project?.taskmaster || null);
} catch (err) {
console.error('Error in setCurrentProject:', err);
handleError(err, 'set current project');
// Fall back to project data if available
setProjectTaskMaster(project?.taskmaster || null);
}
}, []);