diff --git a/.nvmrc b/.nvmrc index 09c06f5..92f279e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.3 \ No newline at end of file +v22 \ No newline at end of file diff --git a/README.md b/README.md index 2303365..fae57a6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla ### Prerequisites -- [Node.js](https://nodejs.org/) v20 or higher +- [Node.js](https://nodejs.org/) v22 or higher - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or - [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or - [Codex](https://developers.openai.com/codex) installed and configured diff --git a/README.zh-CN.md b/README.zh-CN.md index b62cd21..64981c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,7 +57,7 @@ ### 前置要求 -- [Node.js](https://nodejs.org/) v20 或更高版本 +- [Node.js](https://nodejs.org/) v22 或更高版本 - 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或 - 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或 - 已安装并配置 [Codex](https://developers.openai.com/codex) diff --git a/package-lock.json b/package-lock.json index fcd70ff..9bbab5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.29", diff --git a/package.json b/package.json index a5a9732..c2ae2b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "server/index.js", diff --git a/server/index.js b/server/index.js index 8a2604d..bac8e0b 100755 --- a/server/index.js +++ b/server/index.js @@ -63,8 +63,24 @@ import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; -// File system watcher for projects folder -let projectsWatcher = null; +// File system watchers for provider project/session folders +const PROVIDER_WATCH_PATHS = [ + { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, + { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, + { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') } +]; +const WATCHER_IGNORED_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/*.tmp', + '**/*.swp', + '**/.DS_Store' +]; +const WATCHER_DEBOUNCE_MS = 300; +let projectsWatchers = []; +let projectsWatcherDebounceTimer = null; const connectedClients = new Set(); let isGetProjectsRunning = false; // Flag to prevent reentrant calls @@ -81,94 +97,110 @@ function broadcastProgress(progress) { }); } -// Setup file system watcher for Claude projects folder using chokidar +// Setup file system watchers for Claude, Cursor, and Codex project/session folders async function setupProjectsWatcher() { const chokidar = (await import('chokidar')).default; - const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); - if (projectsWatcher) { - projectsWatcher.close(); + if (projectsWatcherDebounceTimer) { + clearTimeout(projectsWatcherDebounceTimer); + projectsWatcherDebounceTimer = null; } - try { - // Initialize chokidar watcher with optimized settings - projectsWatcher = chokidar.watch(claudeProjectsPath, { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - '**/*.tmp', - '**/*.swp', - '**/.DS_Store' - ], - persistent: true, - ignoreInitial: true, // Don't fire events for existing files on startup - followSymlinks: false, - depth: 10, // Reasonable depth limit - awaitWriteFinish: { - stabilityThreshold: 100, // Wait 100ms for file to stabilize - pollInterval: 50 + await Promise.all( + projectsWatchers.map(async (watcher) => { + try { + await watcher.close(); + } catch (error) { + console.error('[WARN] Failed to close watcher:', error); } - }); + }) + ); + projectsWatchers = []; - // Debounce function to prevent excessive notifications - let debounceTimer; - const debouncedUpdate = async (eventType, filePath) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { - // Prevent reentrant calls - if (isGetProjectsRunning) { - return; + const debouncedUpdate = (eventType, filePath, provider, rootPath) => { + if (projectsWatcherDebounceTimer) { + clearTimeout(projectsWatcherDebounceTimer); + } + + projectsWatcherDebounceTimer = setTimeout(async () => { + // Prevent reentrant calls + if (isGetProjectsRunning) { + return; + } + + try { + isGetProjectsRunning = true; + + // Clear project directory cache when files change + clearProjectDirectoryCache(); + + // Get updated projects list + const updatedProjects = await getProjects(broadcastProgress); + + // Notify all connected clients about the project changes + const updateMessage = JSON.stringify({ + type: 'projects_updated', + projects: updatedProjects, + timestamp: new Date().toISOString(), + changeType: eventType, + changedFile: path.relative(rootPath, filePath), + watchProvider: provider + }); + + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(updateMessage); + } + }); + + } catch (error) { + console.error('[ERROR] Error handling project changes:', error); + } finally { + isGetProjectsRunning = false; + } + }, WATCHER_DEBOUNCE_MS); + }; + + for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { + try { + // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. + // Ensure provider folders exist before creating the watcher so watching stays active. + await fsPromises.mkdir(rootPath, { recursive: true }); + + // Initialize chokidar watcher with optimized settings + const watcher = chokidar.watch(rootPath, { + ignored: WATCHER_IGNORED_PATTERNS, + persistent: true, + ignoreInitial: true, // Don't fire events for existing files on startup + followSymlinks: false, + depth: 10, // Reasonable depth limit + awaitWriteFinish: { + stabilityThreshold: 100, // Wait 100ms for file to stabilize + pollInterval: 50 } - - try { - isGetProjectsRunning = true; - - // Clear project directory cache when files change - clearProjectDirectoryCache(); - - // Get updated projects list - const updatedProjects = await getProjects(broadcastProgress); - - // Notify all connected clients about the project changes - const updateMessage = JSON.stringify({ - type: 'projects_updated', - projects: updatedProjects, - timestamp: new Date().toISOString(), - changeType: eventType, - changedFile: path.relative(claudeProjectsPath, filePath) - }); - - connectedClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(updateMessage); - } - }); - - } catch (error) { - console.error('[ERROR] Error handling project changes:', error); - } finally { - isGetProjectsRunning = false; - } - }, 300); // 300ms debounce (slightly faster than before) - }; - - // Set up event listeners - projectsWatcher - .on('add', (filePath) => debouncedUpdate('add', filePath)) - .on('change', (filePath) => debouncedUpdate('change', filePath)) - .on('unlink', (filePath) => debouncedUpdate('unlink', filePath)) - .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath)) - .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath)) - .on('error', (error) => { - console.error('[ERROR] Chokidar watcher error:', error); - }) - .on('ready', () => { }); - } catch (error) { - console.error('[ERROR] Failed to setup projects watcher:', error); + // Set up event listeners + watcher + .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath)) + .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath)) + .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath)) + .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath)) + .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath)) + .on('error', (error) => { + console.error(`[ERROR] ${provider} watcher error:`, error); + }) + .on('ready', () => { + }); + + projectsWatchers.push(watcher); + } catch (error) { + console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error); + } + } + + if (projectsWatchers.length === 0) { + console.error('[ERROR] Failed to setup any provider watchers'); } } diff --git a/server/openai-codex.js b/server/openai-codex.js index 1967de4..bd368ff 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; let currentSessionId = sessionId; + const abortController = new AbortController(); try { // Initialize Codex SDK @@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) { thread, codex, status: 'running', + abortController, startedAt: new Date().toISOString() }); @@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) { }); // Execute with streaming - const streamedTurn = await thread.runStreamed(command); + const streamedTurn = await thread.runStreamed(command, { + signal: abortController.signal + }); for await (const event of streamedTurn.events) { // Check if session was aborted @@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) { }); } catch (error) { - console.error('[Codex] Error:', error); + const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; + const wasAborted = + session?.status === 'aborted' || + error?.name === 'AbortError' || + String(error?.message || '').toLowerCase().includes('aborted'); - sendMessage(ws, { - type: 'codex-error', - error: error.message, - sessionId: currentSessionId - }); + if (!wasAborted) { + console.error('[Codex] Error:', error); + sendMessage(ws, { + type: 'codex-error', + error: error.message, + sessionId: currentSessionId + }); + } } finally { // Update session status if (currentSessionId) { const session = activeCodexSessions.get(currentSessionId); if (session) { - session.status = 'completed'; + session.status = session.status === 'aborted' ? 'aborted' : 'completed'; } } } @@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) { } session.status = 'aborted'; - - // The SDK doesn't have a direct abort method, but marking status - // will cause the streaming loop to exit + try { + session.abortController?.abort(); + } catch (error) { + console.warn(`[Codex] Failed to abort session ${sessionId}:`, error); + } return true; } diff --git a/server/projects.js b/server/projects.js index 475b323..50a22c5 100755 --- a/server/projects.js +++ b/server/projects.js @@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) { const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); + const codexSessionsIndexRef = { sessionsByProject: null }; let totalProjects = 0; let processedProjects = 0; let directories = []; @@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) { }); } - const projectPath = path.join(claudeDir, entry.name); - // Extract actual project directory from JSONL sessions const actualProjectDir = await extractProjectDirectory(entry.name); @@ -435,7 +434,11 @@ async function getProjects(progressCallback = null) { displayName: customName || autoDisplayName, fullPath: fullPath, isCustomName: !!customName, - sessions: [] + sessions: [], + sessionMeta: { + hasMore: false, + total: 0 + } }; // Try to get sessions for this project (just first 5 for performance) @@ -448,6 +451,10 @@ async function getProjects(progressCallback = null) { }; } catch (e) { console.warn(`Could not load sessions for project ${entry.name}:`, e.message); + project.sessionMeta = { + hasMore: false, + total: 0 + }; } // Also fetch Cursor sessions for this project @@ -460,7 +467,9 @@ async function getProjects(progressCallback = null) { // Also fetch Codex sessions for this project try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); project.codexSessions = []; @@ -525,7 +534,7 @@ async function getProjects(progressCallback = null) { } } - const project = { + const project = { name: projectName, path: actualProjectDir, displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), @@ -533,9 +542,13 @@ async function getProjects(progressCallback = null) { isCustomName: !!projectConfig.displayName, isManuallyAdded: true, sessions: [], + sessionMeta: { + hasMore: false, + total: 0 + }, cursorSessions: [], codexSessions: [] - }; + }; // Try to fetch Cursor sessions for manual projects too try { @@ -546,7 +559,9 @@ async function getProjects(progressCallback = null) { // Try to fetch Codex sessions for manual projects too try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); } @@ -1244,75 +1259,114 @@ async function getCursorSessions(projectPath) { } +function normalizeComparablePath(inputPath) { + if (!inputPath || typeof inputPath !== 'string') { + return ''; + } + + const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\') + ? inputPath.slice(4) + : inputPath; + const normalized = path.normalize(withoutLongPathPrefix.trim()); + + if (!normalized) { + return ''; + } + + const resolved = path.resolve(normalized); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +async function findCodexJsonlFiles(dir) { + const files = []; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findCodexJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (error) { + // Skip directories we can't read + } + + return files; +} + +async function buildCodexSessionsIndex() { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessionsByProject = new Map(); + + try { + await fs.access(codexSessionsDir); + } catch (error) { + return sessionsByProject; + } + + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + if (!sessionData || !sessionData.id) { + continue; + } + + const normalizedProjectPath = normalizeComparablePath(sessionData.cwd); + if (!normalizedProjectPath) { + continue; + } + + const session = { + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + filePath, + provider: 'codex', + }; + + if (!sessionsByProject.has(normalizedProjectPath)) { + sessionsByProject.set(normalizedProjectPath, []); + } + + sessionsByProject.get(normalizedProjectPath).push(session); + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + } + } + + for (const sessions of sessionsByProject.values()) { + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + } + + return sessionsByProject; +} + // Fetch Codex sessions for a given project path async function getCodexSessions(projectPath, options = {}) { - const { limit = 5 } = options; + const { limit = 5, indexRef = null } = options; try { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const sessions = []; - - // Check if the directory exists - try { - await fs.access(codexSessionsDir); - } catch (error) { - // No Codex sessions directory + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) { return []; } - // Recursively find all .jsonl files in the sessions directory - const findJsonlFiles = async (dir) => { - const files = []; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...await findJsonlFiles(fullPath)); - } else if (entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch (error) { - // Skip directories we can't read - } - return files; - }; - - const jsonlFiles = await findJsonlFiles(codexSessionsDir); - - // Process each file to find sessions matching the project path - for (const filePath of jsonlFiles) { - try { - const sessionData = await parseCodexSessionFile(filePath); - - // Check if this session matches the project path - // Handle Windows long paths with \\?\ prefix - const sessionCwd = sessionData?.cwd || ''; - const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd; - const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath; - - if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) { - sessions.push({ - id: sessionData.id, - summary: sessionData.summary || 'Codex Session', - messageCount: sessionData.messageCount || 0, - lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), - cwd: sessionData.cwd, - model: sessionData.model, - filePath: filePath, - provider: 'codex' - }); - } - } catch (error) { - console.warn(`Could not parse Codex session file ${filePath}:`, error.message); - } + if (indexRef && !indexRef.sessionsByProject) { + indexRef.sessionsByProject = await buildCodexSessionsIndex(); } - // Sort sessions by last activity (newest first) - sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex(); + const sessions = sessionsByProject.get(normalizedProjectPath) || []; // Return limited sessions for performance (0 = unlimited for deletion) - return limit > 0 ? sessions.slice(0, limit) : sessions; + return limit > 0 ? sessions.slice(0, limit) : [...sessions]; } catch (error) { console.error('Error fetching Codex sessions:', error); diff --git a/server/routes/commands.js b/server/routes/commands.js index b13a8f3..5446734 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -209,6 +209,86 @@ Custom commands can be created in: }; }, + '/cost': async (args, context) => { + const tokenUsage = context?.tokenUsage || {}; + const provider = context?.provider || 'claude'; + const model = + context?.model || + (provider === 'cursor' + ? CURSOR_MODELS.DEFAULT + : provider === 'codex' + ? CODEX_MODELS.DEFAULT + : CLAUDE_MODELS.DEFAULT); + + const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0; + const total = + Number( + tokenUsage.total ?? + tokenUsage.contextWindow ?? + parseInt(process.env.CONTEXT_WINDOW || '160000', 10), + ) || 160000; + const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0; + + const inputTokensRaw = + Number( + tokenUsage.inputTokens ?? + tokenUsage.input ?? + tokenUsage.cumulativeInputTokens ?? + tokenUsage.promptTokens ?? + 0, + ) || 0; + const outputTokens = + Number( + tokenUsage.outputTokens ?? + tokenUsage.output ?? + tokenUsage.cumulativeOutputTokens ?? + tokenUsage.completionTokens ?? + 0, + ) || 0; + const cacheTokens = + Number( + tokenUsage.cacheReadTokens ?? + tokenUsage.cacheCreationTokens ?? + tokenUsage.cacheTokens ?? + tokenUsage.cachedTokens ?? + 0, + ) || 0; + + // If we only have total used tokens, treat them as input for display/estimation. + const inputTokens = + inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used; + + // Rough default rates by provider (USD / 1M tokens). + const pricingByProvider = { + claude: { input: 3, output: 15 }, + cursor: { input: 3, output: 15 }, + codex: { input: 1.5, output: 6 }, + }; + const rates = pricingByProvider[provider] || pricingByProvider.claude; + + const inputCost = (inputTokens / 1_000_000) * rates.input; + const outputCost = (outputTokens / 1_000_000) * rates.output; + const totalCost = inputCost + outputCost; + + return { + type: 'builtin', + action: 'cost', + data: { + tokenUsage: { + used, + total, + percentage, + }, + cost: { + input: inputCost.toFixed(4), + output: outputCost.toFixed(4), + total: totalCost.toFixed(4), + }, + model, + }, + }; + }, + '/status': async (args, context) => { // Read version from package.json const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); diff --git a/server/routes/git.js b/server/routes/git.js index 0df4e44..e6fecee 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,5 +1,5 @@ import express from 'express'; -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { promises as fs } from 'fs'; @@ -10,6 +10,43 @@ import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); const execAsync = promisify(exec); +function spawnAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + ...options, + shell: false, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + const error = new Error(`Command failed: ${command} ${args.join(' ')}`); + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { try { @@ -60,19 +97,16 @@ async function validateGitRepository(projectPath) { } try { - // Use --show-toplevel to get the root of the git repository - const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); - const normalizedGitRoot = path.resolve(gitRoot.trim()); - const normalizedProjectPath = path.resolve(projectPath); - - // Ensure the git root matches our project path (prevent using parent git repos) - if (normalizedGitRoot !== normalizedProjectPath) { - throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`); - } - } catch (error) { - if (error.message.includes('Project directory is not a git repository')) { - throw error; + // Allow any directory that is inside a work tree (repo root or nested folder). + const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath }); + const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true'; + if (!isInsideWorkTree) { + throw new Error('Not inside a git work tree'); } + + // Ensure git can resolve the repository root for this directory. + await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + } catch { throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } } @@ -445,11 +479,17 @@ router.get('/commits', async (req, res) => { try { const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const parsedLimit = Number.parseInt(String(limit), 10); + const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, 100) + : 10; // Get commit log with stats - const { stdout } = await execAsync( - `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, - { cwd: projectPath } + const { stdout } = await spawnAsync( + 'git', + ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)], + { cwd: projectPath }, ); const commits = stdout @@ -1125,4 +1165,4 @@ router.post('/delete-untracked', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 3ded1e6..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,1011 +0,0 @@ -/* - * App.jsx - Main Application Component with Session Protection System - * - * SESSION PROTECTION SYSTEM OVERVIEW: - * =================================== - * - * Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages - * during active conversations, creating a poor user experience. - * - * Solution: Track "active sessions" and pause project updates during conversations. - * - * How it works: - * 1. When user sends message → session marked as "active" - * 2. Project updates are skipped while session is active - * 3. When conversation completes/aborts → session marked as "inactive" - * 4. Project updates resume normally - * - * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). - */ - -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; -import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; -import Sidebar from './components/Sidebar'; -import MainContent from './components/MainContent'; -import MobileNav from './components/MobileNav'; -import Settings from './components/Settings'; -import QuickSettingsPanel from './components/QuickSettingsPanel'; - -import { ThemeProvider } from './contexts/ThemeContext'; -import { AuthProvider } from './contexts/AuthContext'; -import { TaskMasterProvider } from './contexts/TaskMasterContext'; -import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; -import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext'; -import ProtectedRoute from './components/ProtectedRoute'; -import { useVersionCheck } from './hooks/useVersionCheck'; -import useLocalStorage from './hooks/useLocalStorage'; -import { api, authenticatedFetch } from './utils/api'; -import { I18nextProvider, useTranslation } from 'react-i18next'; -import i18n from './i18n/config.js'; - - -// ! Move to a separate file called AppContent.ts -// Main App component with routing -function AppContent() { - const navigate = useNavigate(); - const { sessionId } = useParams(); - const { t } = useTranslation('common'); - // * This is a tracker for avoiding excessive re-renders during development - const renderCountRef = useRef(0); - // console.log(`AppContent render count: ${renderCountRef.current++}`); - - const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui'); - const [showVersionModal, setShowVersionModal] = useState(false); - - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files' - const [isMobile, setIsMobile] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [isLoadingProjects, setIsLoadingProjects] = useState(true); - const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject } - const [isInputFocused, setIsInputFocused] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); - const [showQuickSettings, setShowQuickSettings] = useState(false); - const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false); - const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false); - const [showThinking, setShowThinking] = useLocalStorage('showThinking', true); - const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true); - const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false); - const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true); - // Session Protection System: Track sessions with active conversations to prevent - // automatic project updates from interrupting ongoing chats. When a user sends - // a message, the session is marked as "active" and project updates are paused - // until the conversation completes or is aborted. - const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations - - // Processing Sessions: Track which sessions are currently thinking/processing - // This allows us to restore the "Thinking..." banner when switching back to a processing session - const [processingSessions, setProcessingSessions] = useState(new Set()); - - // External Message Update Trigger: Incremented when external CLI modifies current session's JSONL - // Triggers ChatInterface to reload messages without switching sessions - const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); - - const { ws, sendMessage, latestMessage } = useWebSocket(); - - // Ref to track loading progress timeout for cleanup - const loadingProgressTimeoutRef = useRef(null); - - // Detect if running as PWA - const [isPWA, setIsPWA] = useState(false); - - useEffect(() => { - // Check if running in standalone mode (PWA) - const checkPWA = () => { - const isStandalone = window.matchMedia('(display-mode: standalone)').matches || - window.navigator.standalone || - document.referrer.includes('android-app://'); - setIsPWA(isStandalone); - document.addEventListener('touchstart', {}); - - // Add class to html and body for CSS targeting - if (isStandalone) { - document.documentElement.classList.add('pwa-mode'); - document.body.classList.add('pwa-mode'); - } else { - document.documentElement.classList.remove('pwa-mode'); - document.body.classList.remove('pwa-mode'); - } - }; - - checkPWA(); - - // Listen for changes - window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA); - - return () => { - window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA); - }; - }, []); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - - return () => window.removeEventListener('resize', checkMobile); - }, []); - - useEffect(() => { - // Fetch projects on component mount - fetchProjects(); - }, []); - - // Helper function to determine if an update is purely additive (new sessions/projects) - // vs modifying existing selected items that would interfere with active conversations - const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => { - if (!selectedProject || !selectedSession) { - // No active session to protect, allow all updates - return true; - } - - // Find the selected project in both current and updated data - const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name); - const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name); - - if (!currentSelectedProject || !updatedSelectedProject) { - // Project structure changed significantly, not purely additive - return false; - } - - // Find the selected session in both current and updated project data - const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id); - const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); - - if (!currentSelectedSession || !updatedSelectedSession) { - // Selected session was deleted or significantly changed, not purely additive - return false; - } - - // Check if the selected session's content has changed (modification vs addition) - // Compare key fields that would affect the loaded chat interface - const sessionUnchanged = - currentSelectedSession.id === updatedSelectedSession.id && - currentSelectedSession.title === updatedSelectedSession.title && - currentSelectedSession.created_at === updatedSelectedSession.created_at && - currentSelectedSession.updated_at === updatedSelectedSession.updated_at; - - // This is considered additive if the selected session is unchanged - // (new sessions may have been added elsewhere, but active session is protected) - return sessionUnchanged; - }; - - // Handle WebSocket messages for real-time project updates - useEffect(() => { - if (latestMessage) { - // Handle loading progress updates - if (latestMessage.type === 'loading_progress') { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - setLoadingProgress(latestMessage); - if (latestMessage.phase === 'complete') { - loadingProgressTimeoutRef.current = setTimeout(() => { - setLoadingProgress(null); - loadingProgressTimeoutRef.current = null; - }, 500); - } - return; - } - - if (latestMessage.type === 'projects_updated') { - - // External Session Update Detection: Check if the changed file is the current session's JSONL - // If so, and the session is not active, trigger a message reload in ChatInterface - if (latestMessage.changedFile && selectedSession && selectedProject) { - // Extract session ID from changedFile (format: "project-name/session-id.jsonl") - const normalized = latestMessage.changedFile.replace(/\\/g, '/'); - const changedFileParts = normalized.split('/'); - - if (changedFileParts.length >= 2) { - const filename = changedFileParts[changedFileParts.length - 1]; - const changedSessionId = filename.replace('.jsonl', ''); - - // Check if this is the currently-selected session - if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - // Session is not active - safe to reload messages - setExternalMessageUpdate(prev => prev + 1); - } - } - } - } - - // Session Protection Logic: Allow additions but prevent changes during active conversations - // This allows new sessions/projects to appear in sidebar while protecting active chat messages - // We check for two types of active sessions: - // 1. Existing sessions: selectedSession.id exists in activeSessions - // 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received) - const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) || - (activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-'))); - - if (hasActiveSession) { - // Allow updates but be selective: permit additions, prevent changes to existing items - const updatedProjects = latestMessage.projects; - const currentProjects = projects; - - // Check if this is purely additive (new sessions/projects) vs modification of existing ones - const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession); - - if (!isAdditiveUpdate) { - // Skip updates that would modify existing selected session/project - return; - } - // Continue with additive updates below - } - - // Update projects state with the new data from WebSocket - const updatedProjects = latestMessage.projects; - setProjects(updatedProjects); - - // Update selected project if it exists in the updated projects - if (selectedProject) { - const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name); - if (updatedSelectedProject) { - // Only update selected project if it actually changed - prevents flickering - if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(updatedSelectedProject); - } - - if (selectedSession) { - const allSessions = [ - ...(updatedSelectedProject.sessions || []), - ...(updatedSelectedProject.codexSessions || []), - ...(updatedSelectedProject.cursorSessions || []) - ]; - const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id); - if (!updatedSelectedSession) { - setSelectedSession(null); - } - } - } - } - } - } - - return () => { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - }; - }, [latestMessage, selectedProject, selectedSession, activeSessions]); - - const fetchProjects = async () => { - try { - setIsLoadingProjects(true); - const response = await api.projects(); - const data = await response.json(); - - // Always fetch Cursor sessions for each project so we can combine views - for (let project of data) { - try { - const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`; - const cursorResponse = await authenticatedFetch(url); - if (cursorResponse.ok) { - const cursorData = await cursorResponse.json(); - if (cursorData.success && cursorData.sessions) { - project.cursorSessions = cursorData.sessions; - } else { - project.cursorSessions = []; - } - } else { - project.cursorSessions = []; - } - } catch (error) { - console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); - project.cursorSessions = []; - } - } - - // Optimize to preserve object references when data hasn't changed - setProjects(prevProjects => { - // If no previous projects, just set the new data - if (prevProjects.length === 0) { - return data; - } - - // Check if the projects data has actually changed - const hasChanges = data.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - // Compare key properties that would affect UI - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) || - JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions) - ); - }) || data.length !== prevProjects.length; - - // Only update if there are actual changes - return hasChanges ? data : prevProjects; - }); - - // Don't auto-select any project - user should choose manually - } catch (error) { - console.error('Error fetching projects:', error); - } finally { - setIsLoadingProjects(false); - } - }; - - // Expose fetchProjects globally for component access - window.refreshProjects = fetchProjects; - - // Expose openSettings function globally for component access - window.openSettings = useCallback((tab = 'tools') => { - setSettingsInitialTab(tab); - setShowSettings(true); - }, []); - - // Handle URL-based session loading - useEffect(() => { - if (sessionId && projects.length > 0) { - // Only switch tabs on initial load, not on every project update - const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; - // Find the session across all projects - for (const project of projects) { - let session = project.sessions?.find(s => s.id === sessionId); - if (session) { - setSelectedProject(project); - setSelectedSession({ ...session, __provider: 'claude' }); - // Only switch to chat tab if we're loading a different session - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - // Also check Cursor sessions - const cSession = project.cursorSessions?.find(s => s.id === sessionId); - if (cSession) { - setSelectedProject(project); - setSelectedSession({ ...cSession, __provider: 'cursor' }); - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - } - - // If session not found, it might be a newly created session - // Just navigate to it and it will be found when the sidebar refreshes - // Don't redirect to home, let the session load naturally - } - }, [sessionId, projects, navigate]); - - const handleProjectSelect = (project) => { - setSelectedProject(project); - setSelectedSession(null); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionSelect = (session) => { - setSelectedSession(session); - // Only switch to chat tab when user explicitly selects a session - // This prevents tab switching during automatic updates - if (activeTab !== 'git' && activeTab !== 'preview') { - setActiveTab('chat'); - } - - // For Cursor sessions, we need to set the session ID differently - // since they're persistent and not created by Claude - const provider = localStorage.getItem('selected-provider') || 'claude'; - if (provider === 'cursor') { - // Cursor sessions have persistent IDs - sessionStorage.setItem('cursorSessionId', session.id); - } - - // Only close sidebar on mobile if switching to a different project - if (isMobile) { - const sessionProjectName = session.__projectName; - const currentProjectName = selectedProject?.name; - - // Close sidebar if clicking a session from a different project - // Keep it open if clicking a session from the same project - if (sessionProjectName !== currentProjectName) { - setSidebarOpen(false); - } - } - navigate(`/session/${session.id}`); - }; - - const handleNewSession = (project) => { - setSelectedProject(project); - setSelectedSession(null); - setActiveTab('chat'); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionDelete = (sessionId) => { - // If the deleted session was currently selected, clear it - if (selectedSession?.id === sessionId) { - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.map(project => ({ - ...project, - sessions: project.sessions?.filter(session => session.id !== sessionId) || [], - sessionMeta: { - ...project.sessionMeta, - total: Math.max(0, (project.sessionMeta?.total || 0) - 1) - } - })) - ); - }; - - - - const handleSidebarRefresh = async () => { - // Refresh only the sessions for all projects, don't change selected state - try { - const response = await api.projects(); - const freshProjects = await response.json(); - - // Optimize to preserve object references and minimize re-renders - setProjects(prevProjects => { - // Check if projects data has actually changed - const hasChanges = freshProjects.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) - ); - }) || freshProjects.length !== prevProjects.length; - - return hasChanges ? freshProjects : prevProjects; - }); - - // If we have a selected project, make sure it's still selected after refresh - if (selectedProject) { - const refreshedProject = freshProjects.find(p => p.name === selectedProject.name); - if (refreshedProject) { - // Only update selected project if it actually changed - if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(refreshedProject); - } - - // If we have a selected session, try to find it in the refreshed project - if (selectedSession) { - const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id); - if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) { - setSelectedSession(refreshedSession); - } - } - } - } - } catch (error) { - console.error('Error refreshing sidebar:', error); - } - }; - - const handleProjectDelete = (projectName) => { - // If the deleted project was currently selected, clear it - if (selectedProject?.name === projectName) { - setSelectedProject(null); - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.filter(project => project.name !== projectName) - ); - }; - - // Session Protection Functions: Manage the lifecycle of active sessions - - // markSessionAsActive: Called when user sends a message to mark session as protected - // This includes both real session IDs and temporary "new-session-*" identifiers - const markSessionAsActive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates - const markSessionAsInactive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // Processing Session Functions: Track which sessions are currently thinking/processing - - // markSessionAsProcessing: Called when Claude starts thinking/processing - const markSessionAsProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsNotProcessing: Called when Claude finishes thinking/processing - const markSessionAsNotProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions - // Removes temporary "new-session-*" identifiers and adds the real session ID - // This maintains protection continuity during the transition from temporary to real session - const replaceTemporarySession = useCallback((realSessionId) => { - if (realSessionId) { - setActiveSessions(prev => { - const newSet = new Set(); - // Keep all non-temporary sessions and add the real session ID - for (const sessionId of prev) { - if (!sessionId.startsWith('new-session-')) { - newSet.add(sessionId); - } - } - newSet.add(realSessionId); - return newSet; - }); - } - }, []); - - // Version Upgrade Modal Component - const VersionUpgradeModal = () => { - const { t } = useTranslation('common'); - const [isUpdating, setIsUpdating] = useState(false); - const [updateOutput, setUpdateOutput] = useState(''); - const [updateError, setUpdateError] = useState(''); - - if (!showVersionModal) return null; - - // Clean up changelog by removing GitHub-specific metadata - const cleanChangelog = (body) => { - if (!body) return ''; - - return body - // Remove full commit hashes (40 character hex strings) - .replace(/\b[0-9a-f]{40}\b/gi, '') - // Remove short commit hashes (7-10 character hex strings at start of line or after dash/space) - .replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '') - // Remove "Full Changelog" links - .replace(/\*\*Full Changelog\*\*:.*$/gim, '') - // Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1) - .replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '') - // Clean up multiple consecutive empty lines - .replace(/\n\s*\n\s*\n/g, '\n\n') - // Trim whitespace - .trim(); - }; - - const handleUpdateNow = async () => { - setIsUpdating(true); - setUpdateOutput('Starting update...\n'); - setUpdateError(''); - - try { - // Call the backend API to run the update command - const response = await authenticatedFetch('/api/system/update', { - method: 'POST', - }); - - const data = await response.json(); - - if (response.ok) { - setUpdateOutput(prev => prev + data.output + '\n'); - setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n'); - setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n'); - } else { - setUpdateError(data.error || 'Update failed'); - setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n'); - } - } catch (error) { - setUpdateError(error.message); - setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n'); - } finally { - setIsUpdating(false); - } - }; - - return ( -
- {/* Backdrop */} - -
- - {/* Version Info */} -
-
- {t('versionUpdate.currentVersion')} - {currentVersion} -
-
- {t('versionUpdate.latestVersion')} - {latestVersion} -
-
- - {/* Changelog */} - {releaseInfo?.body && ( -
-
-

{t('versionUpdate.whatsNew')}

- {releaseInfo?.htmlUrl && ( - - {t('versionUpdate.viewFullRelease')} - - - - - )} -
-
-
- {cleanChangelog(releaseInfo.body)} -
-
-
- )} - - {/* Update Output */} - {updateOutput && ( -
-

{t('versionUpdate.updateProgress')}

-
-
{updateOutput}
-
-
- )} - - {/* Upgrade Instructions */} - {!isUpdating && !updateOutput && ( -
-

{t('versionUpdate.manualUpgrade')}

-
- - git checkout main && git pull && npm install - -
-

- {t('versionUpdate.manualUpgradeHint')} -

-
- )} - - {/* Actions */} -
- - {!updateOutput && ( - <> - - - - )} -
- - - ); - }; - - return ( -
- {/* Fixed Desktop Sidebar */} - {!isMobile && ( -
-
- {sidebarVisible ? ( - setShowSettings(true)} - updateAvailable={updateAvailable} - latestVersion={latestVersion} - currentVersion={currentVersion} - releaseInfo={releaseInfo} - onShowVersionModal={() => setShowVersionModal(true)} - isPWA={isPWA} - isMobile={isMobile} - onToggleSidebar={() => setSidebarVisible(false)} - /> - ) : ( - /* Collapsed Sidebar */ -
- {/* Expand Button */} - - - {/* Settings Icon */} - - - {/* Update Indicator */} - {updateAvailable && ( - - )} -
- )} -
-
- )} - - {/* Mobile Sidebar Overlay */} - {isMobile && ( -
-
- )} - - {/* Main Content Area - Flexible */} -
- setSidebarOpen(true)} - isLoading={isLoadingProjects} - onInputFocusChange={setIsInputFocused} - onSessionActive={markSessionAsActive} - onSessionInactive={markSessionAsInactive} - onSessionProcessing={markSessionAsProcessing} - onSessionNotProcessing={markSessionAsNotProcessing} - processingSessions={processingSessions} - onReplaceTemporarySession={replaceTemporarySession} - onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)} - onShowSettings={() => setShowSettings(true)} - autoExpandTools={autoExpandTools} - showRawParameters={showRawParameters} - showThinking={showThinking} - autoScrollToBottom={autoScrollToBottom} - sendByCtrlEnter={sendByCtrlEnter} - externalMessageUpdate={externalMessageUpdate} - /> -
- - {/* Mobile Bottom Navigation */} - {isMobile && ( - - )} - {/* Quick Settings Panel - Only show on chat tab */} - {activeTab === 'chat' && ( - - )} - - {/* Settings Modal */} - setShowSettings(false)} - projects={projects} - initialTab={settingsInitialTab} - /> - - {/* Version Upgrade Modal */} - -
- ); -} - -// Root App component with router -function App() { - return ( - - - - - - - - - - } /> - } /> - - - - - - - - - - ); -} - -export default App; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8593236 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { AuthProvider } from './contexts/AuthContext'; +import { TaskMasterProvider } from './contexts/TaskMasterContext'; +import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; +import { WebSocketProvider } from './contexts/WebSocketContext'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppContent from './components/app/AppContent'; +import i18n from './i18n/config.js'; + +export default function App() { + return ( + + + + + + + + + + } /> + } /> + + + + + + + + + + ); +} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx deleted file mode 100644 index d3d7bea..0000000 --- a/src/components/ChatInterface.jsx +++ /dev/null @@ -1,5692 +0,0 @@ -/* - * ChatInterface.jsx - Chat Component with Session Protection Integration - * - * SESSION PROTECTION INTEGRATION: - * =============================== - * - * This component integrates with the Session Protection System to prevent project updates - * from interrupting active conversations: - * - * Key Integration Points: - * 1. handleSubmit() - Marks session as active when user sends message (including temp ID for new sessions) - * 2. session-created handler - Replaces temporary session ID with real WebSocket session ID - * 3. claude-complete handler - Marks session as inactive when conversation finishes - * 4. session-aborted handler - Marks session as inactive when conversation is aborted - * - * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. - */ - -import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { useDropzone } from 'react-dropzone'; -import TodoList from './TodoList'; -import ClaudeLogo from './ClaudeLogo.jsx'; -import CursorLogo from './CursorLogo.jsx'; -import CodexLogo from './CodexLogo.jsx'; -import NextTaskBanner from './NextTaskBanner.jsx'; -import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import { useTranslation } from 'react-i18next'; - -import ClaudeStatus from './ClaudeStatus'; -import TokenUsagePie from './TokenUsagePie'; -import { MicButton } from './MicButton.jsx'; -import { api, authenticatedFetch } from '../utils/api'; -import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx'; -import Fuse from 'fuse.js'; -import CommandMenu from './CommandMenu'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; - -import { safeJsonParse } from '../lib/utils.js'; - -// ! Move all utility functions to utils/chatUtils.ts - -// Helper function to decode HTML entities in text -function decodeHtmlEntities(text) { - if (!text) return text; - return text - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); -} - -// Normalize markdown text where providers mistakenly wrap short inline code with single-line triple fences. -// Only convert fences that do NOT contain any newline to avoid touching real code blocks. -function normalizeInlineCodeFences(text) { - if (!text || typeof text !== 'string') return text; - try { - // ```code``` -> `code` - return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`'); - } catch { - return text; - } -} - -// Unescape \n, \t, \r while protecting LaTeX formulas ($...$ and $$...$$) from being corrupted -function unescapeWithMathProtection(text) { - if (!text || typeof text !== 'string') return text; - - const mathBlocks = []; - const PLACEHOLDER_PREFIX = '__MATH_BLOCK_'; - const PLACEHOLDER_SUFFIX = '__'; - - // Extract and protect math formulas - let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => { - const index = mathBlocks.length; - mathBlocks.push(match); - return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`; - }); - - // Process escape sequences on non-math content - processedText = processedText.replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\r/g, '\r'); - - // Restore math formulas - processedText = processedText.replace( - new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'), - (match, index) => { - return mathBlocks[parseInt(index)]; - } - ); - - return processedText; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// Small wrapper to keep markdown behavior consistent in one place -const Markdown = ({ children, className }) => { - const content = normalizeInlineCodeFences(String(children ?? '')); - const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); - const rehypePlugins = useMemo(() => [rehypeKatex], []); - - return ( -
- - {content} - -
- ); -}; - -// Format "Claude AI usage limit reached|" into a local time string -function formatUsageLimitText(text) { - try { - if (typeof text !== 'string') return text; - return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => { - let timestampMs = parseInt(ts, 10); - if (!Number.isFinite(timestampMs)) return match; - if (timestampMs < 1e12) timestampMs *= 1000; // seconds → ms - const reset = new Date(timestampMs); - - // Time HH:mm in local time - const timeStr = new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false - }).format(reset); - - // Human-readable timezone: GMT±HH[:MM] (City) - const offsetMinutesLocal = -reset.getTimezoneOffset(); - const sign = offsetMinutesLocal >= 0 ? '+' : '-'; - const abs = Math.abs(offsetMinutesLocal); - const offH = Math.floor(abs / 60); - const offM = abs % 60; - const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`; - const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; - const cityRaw = tzId.split('/').pop() || ''; - const city = cityRaw - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/g, c => c.toUpperCase()); - const tzHuman = city ? `${gmt} (${city})` : gmt; - - // Readable date like "8 Jun 2025" - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`; - - return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`; - }); - } catch { - return text; - } -} - -// Safe localStorage utility to handle quota exceeded errors -const safeLocalStorage = { - setItem: (key, value) => { - try { - // For chat messages, implement compression and size limits - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - // Limit to last 50 messages to prevent storage bloat - if (Array.isArray(parsed) && parsed.length > 50) { - console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`); - const truncated = parsed.slice(-50); - value = JSON.stringify(truncated); - } - } catch (parseError) { - console.warn('Could not parse chat messages for truncation:', parseError); - } - } - - localStorage.setItem(key, value); - } catch (error) { - if (error.name === 'QuotaExceededError') { - console.warn('localStorage quota exceeded, clearing old data'); - // Clear old chat messages to free up space - const keys = Object.keys(localStorage); - const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort(); - - // Remove oldest chat data first, keeping only the 3 most recent projects - if (chatKeys.length > 3) { - chatKeys.slice(0, chatKeys.length - 3).forEach(k => { - localStorage.removeItem(k); - console.log(`Removed old chat data: ${k}`); - }); - } - - // If still failing, clear draft inputs too - const draftKeys = keys.filter(k => k.startsWith('draft_input_')); - draftKeys.forEach(k => { - localStorage.removeItem(k); - }); - - // Try again with reduced data - try { - localStorage.setItem(key, value); - } catch (retryError) { - console.error('Failed to save to localStorage even after cleanup:', retryError); - // Last resort: Try to save just the last 10 messages - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.length > 10) { - const minimal = parsed.slice(-10); - localStorage.setItem(key, JSON.stringify(minimal)); - console.warn('Saved only last 10 messages due to quota constraints'); - } - } catch (finalError) { - console.error('Final save attempt failed:', finalError); - } - } - } - } else { - console.error('localStorage error:', error); - } - } - }, - getItem: (key) => { - try { - return localStorage.getItem(key); - } catch (error) { - console.error('localStorage getItem error:', error); - return null; - } - }, - removeItem: (key) => { - try { - localStorage.removeItem(key); - } catch (error) { - console.error('localStorage removeItem error:', error); - } - } -}; - -const CLAUDE_SETTINGS_KEY = 'claude-settings'; - - -function getClaudeSettings() { - const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); - if (!raw) { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } - - try { - const parsed = JSON.parse(raw); - return { - ...parsed, - allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], - disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], - skipPermissions: Boolean(parsed.skipPermissions), - projectSortOrder: parsed.projectSortOrder || 'name' - }; - } catch { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } -} - -function buildClaudeToolPermissionEntry(toolName, toolInput) { - if (!toolName) return null; - if (toolName !== 'Bash') return toolName; - - const parsed = safeJsonParse(toolInput); - const command = typeof parsed?.command === 'string' ? parsed.command.trim() : ''; - if (!command) return toolName; - - const tokens = command.split(/\s+/); - if (tokens.length === 0) return toolName; - - // For Bash, allow the command family instead of every Bash invocation. - if (tokens[0] === 'git' && tokens[1]) { - return `Bash(${tokens[0]} ${tokens[1]}:*)`; - } - return `Bash(${tokens[0]}:*)`; -} - -// Normalize tool inputs for display in the permission banner. -// This does not sanitize/redact secrets; it is strictly formatting so users -// can see the raw input that triggered the permission prompt. -function formatToolInputForDisplay(input) { - if (input === undefined || input === null) return ''; - if (typeof input === 'string') return input; - try { - return JSON.stringify(input, null, 2); - } catch { - return String(input); - } -} - -function getClaudePermissionSuggestion(message, provider) { - if (provider !== 'claude') return null; - if (!message?.toolResult?.isError) return null; - - const toolName = message?.toolName; - const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput); - - if (!entry) return null; - - const settings = getClaudeSettings(); - const isAllowed = settings.allowedTools.includes(entry); - return { toolName, entry, isAllowed }; -} - -function grantClaudeToolPermission(entry) { - if (!entry) return { success: false }; - - const settings = getClaudeSettings(); - const alreadyAllowed = settings.allowedTools.includes(entry); - const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry]; - const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry); - const updatedSettings = { - ...settings, - allowedTools: nextAllowed, - disallowedTools: nextDisallowed, - lastUpdated: new Date().toISOString() - }; - - safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings)); - return { success: true, alreadyAllowed, updatedSettings }; -} - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const CodeBlock = ({ node, inline, className, children, ...props }) => { - const { t } = useTranslation('chat'); - const [copied, setCopied] = React.useState(false); - const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); - const looksMultiline = /[\r\n]/.test(raw); - const inlineDetected = inline || (node && node.type === 'inlineCode'); - const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line - - // Inline code rendering - if (shouldInline) { - return ( - - {children} - - ); - } - - // Extract language from className (format: language-xxx) - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - const textToCopy = raw; - - const handleCopy = () => { - const doSet = () => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - try { - if (navigator && navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => { - // Fallback - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); } catch {} - document.body.removeChild(ta); - doSet(); - }); - } else { - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); } catch {} - document.body.removeChild(ta); - doSet(); - } - } catch {} - }; - - // Code block with syntax highlighting - return ( -
- {/* Language label */} - {language && language !== 'text' && ( -
- {language} -
- )} - - {/* Copy button */} - - - {/* Syntax highlighted code */} - - {raw} - -
- ); - }; - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const markdownComponents = { - code: CodeBlock, - blockquote: ({ children }) => ( -
- {children} -
- ), - a: ({ href, children }) => ( - - {children} - - ), - p: ({ children }) =>
{children}
, - // GFM tables - table: ({ children }) => ( -
- - {children} -
-
- ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ) -}; - -// Memoized message component to prevent unnecessary re-renders -const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { - const { t } = useTranslation('chat'); - const isGrouped = prevMessage && prevMessage.type === message.type && - ((prevMessage.type === 'assistant') || - (prevMessage.type === 'user') || - (prevMessage.type === 'tool') || - (prevMessage.type === 'error')); - const messageRef = React.useRef(null); - const [isExpanded, setIsExpanded] = React.useState(false); - const permissionSuggestion = getClaudePermissionSuggestion(message, provider); - const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); - - React.useEffect(() => { - setPermissionGrantState('idle'); - }, [permissionSuggestion?.entry, message.toolId]); - - React.useEffect(() => { - if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !isExpanded) { - setIsExpanded(true); - // Find all details elements and open them - const details = messageRef.current.querySelectorAll('details'); - details.forEach(detail => { - detail.open = true; - }); - } - }); - }, - { threshold: 0.1 } - ); - - observer.observe(messageRef.current); - - return () => { - if (messageRef.current) { - observer.unobserve(messageRef.current); - } - }; - }, [autoExpandTools, isExpanded, message.isToolUse]); - - return ( -
- {message.type === 'user' ? ( - /* User message bubble on the right */ -
-
-
- {message.content} -
- {message.images && message.images.length > 0 && ( -
- {message.images.map((img, idx) => ( - {img.name} window.open(img.data, '_blank')} - /> - ))} -
- )} -
- {new Date(message.timestamp).toLocaleTimeString()} -
-
- {!isGrouped && ( -
- U -
- )} -
- ) : ( - /* Claude/Error/Tool messages on the left */ -
- {!isGrouped && ( -
- {message.type === 'error' ? ( -
- ! -
- ) : message.type === 'tool' ? ( -
- 🔧 -
- ) : ( -
- {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( - - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( - - ) : ( - - )} -
- )} -
- {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))} -
-
- )} - -
- - {message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? ( - (() => { - // Minimize Grep and Glob tools since they happen frequently - const isSearchTool = ['Grep', 'Glob'].includes(message.toolName); - - if (isSearchTool) { - return ( - <> -
-
-
- - - - {message.toolName} - - {message.toolInput && (() => { - try { - const input = JSON.parse(message.toolInput); - return ( - - {input.pattern && {t('search.pattern')} {input.pattern}} - {input.path && {t('search.in')} {input.path}} - - ); - } catch (e) { - return null; - } - })()} -
- {message.toolResult && ( - - {t('tools.searchResults')} - - - - - )} -
-
- - ); - } - - // Full display for other tools - return ( -
- {/* Decorative gradient overlay */} -
- -
-
-
- - - - - {/* Subtle pulse animation */} -
-
-
- - {message.toolName} - - - {message.toolId} - -
-
- {onShowSettings && ( - - )} -
- {message.toolInput && message.toolName === 'Edit' && (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path && input.old_string && input.new_string) { - return ( -
- - - - - - View edit diff for - - - -
-
-
- - - Diff - -
-
- {createDiff(input.old_string, input.new_string).map((diffLine, i) => ( -
- - {diffLine.type === 'removed' ? '-' : '+'} - - - {diffLine.content} - -
- ))} -
-
- {showRawParameters && ( -
- - - - - View raw parameters - -
-                                  {message.toolInput}
-                                
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to raw display if parsing fails - } - return ( -
- - - - - View input parameters - -
-                        {message.toolInput}
-                      
-
- ); - })()} - {message.toolInput && message.toolName !== 'Edit' && (() => { - // Debug log to see what we're dealing with - - // Special handling for Write tool - if (message.toolName === 'Write') { - try { - let input; - // Handle both JSON string and already parsed object - if (typeof message.toolInput === 'string') { - input = JSON.parse(message.toolInput); - } else { - input = message.toolInput; - } - - - if (input.file_path && input.content !== undefined) { - return ( -
- - - - - - 📄 - Creating new file: - - - -
-
-
- - - New File - -
-
- {createDiff('', input.content).map((diffLine, i) => ( -
- - {diffLine.type === 'removed' ? '-' : '+'} - - - {diffLine.content} - -
- ))} -
-
- {showRawParameters && ( -
- - - - - View raw parameters - -
-                                    {message.toolInput}
-                                  
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for TodoWrite tool - if (message.toolName === 'TodoWrite') { - try { - const input = JSON.parse(message.toolInput); - if (input.todos && Array.isArray(input.todos)) { - return ( -
- - - - - - - Updating Todo List - - -
- - {showRawParameters && ( -
- - - - - View raw parameters - -
-                                    {message.toolInput}
-                                  
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for Bash tool - if (message.toolName === 'Bash') { - try { - const input = JSON.parse(message.toolInput); - return ( -
-
- $ - {input.command} -
- {input.description && ( -
- {input.description} -
- )} -
- ); - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for Read tool - if (message.toolName === 'Read') { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path) { - const filename = input.file_path.split('/').pop(); - - return ( -
- Read{' '} - -
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for exit_plan_mode tool - if (message.toolName === 'exit_plan_mode') { - try { - const input = JSON.parse(message.toolInput); - if (input.plan) { - // Replace escaped newlines with actual newlines - const planContent = input.plan.replace(/\\n/g, '\n'); - return ( -
- - - - - 📋 View implementation plan - - - {planContent} - -
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Regular tool input display for other tools - return ( -
- - - - - View input parameters - -
-                        {message.toolInput}
-                      
-
- ); - })()} - - {/* Tool Result Section */} - {message.toolResult && (() => { - // Hide tool results for Edit/Write/Bash unless there's an error - const shouldHideResult = !message.toolResult.isError && - (message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash'); - - if (shouldHideResult) { - return null; - } - - return ( -
- {/* Decorative gradient overlay */} -
- -
-
- - {message.toolResult.isError ? ( - - ) : ( - - )} - -
- - {message.toolResult.isError ? 'Tool Error' : 'Tool Result'} - -
- -
- {(() => { - const content = String(message.toolResult.content || ''); - - // Special handling for TodoWrite/TodoRead results - if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') && - (content.includes('Todos have been modified successfully') || - content.includes('Todo list') || - (content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) { - try { - // Try to parse if it looks like todo JSON data - let todos = null; - if (content.startsWith('[')) { - todos = JSON.parse(content); - } else if (content.includes('Todos have been modified successfully')) { - // For TodoWrite success messages, we don't have the data in the result - return ( -
-
- Todo list has been updated successfully -
-
- ); - } - - if (todos && Array.isArray(todos)) { - return ( -
-
- Current Todo List -
- -
- ); - } - } catch (e) { - // Fall through to regular handling - } - } - - // Special handling for exit_plan_mode tool results - if (message.toolName === 'exit_plan_mode') { - try { - // The content should be JSON with a "plan" field - const parsed = JSON.parse(content); - if (parsed.plan) { - // Replace escaped newlines with actual newlines - const planContent = parsed.plan.replace(/\\n/g, '\n'); - return ( -
-
- Implementation Plan -
- - {planContent} - -
- ); - } - } catch (e) { - // Fall through to regular handling - } - } - - // Special handling for Grep/Glob results with structured data - if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) { - const toolData = message.toolResult.toolUseResult; - - // Handle files_with_matches mode or any tool result with filenames array - if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) { - return ( -
-
- - Found {toolData.numFiles || toolData.filenames.length} {(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'} - -
-
- {toolData.filenames.map((filePath, index) => { - const fileName = filePath.split('/').pop(); - const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); - - return ( -
{ - if (onFileOpen) { - onFileOpen(filePath); - } - }} - className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-green-100/50 dark:hover:bg-green-800/20 cursor-pointer transition-colors" - > - - - -
-
- {fileName} -
-
- {dirPath} -
-
- - - -
- ); - })} -
-
- ); - } - } - - // Special handling for interactive prompts - if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') { - const lines = content.split('\n'); - const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?')); - const beforePrompt = lines.slice(0, promptIndex).join('\n'); - const promptLines = lines.slice(promptIndex); - - // Extract the question and options - const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || ''; - const options = []; - - // Parse numbered options (1. Yes, 2. No, etc.) - promptLines.forEach(line => { - const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/); - if (optionMatch) { - options.push({ - number: optionMatch[1], - text: optionMatch[2].trim() - }); - } - }); - - // Find which option was selected (usually indicated by "> 1" or similar) - const selectedMatch = content.match(/>\s*(\d+)/); - const selectedOption = selectedMatch ? selectedMatch[1] : null; - - return ( -
- {beforePrompt && ( -
-
{beforePrompt}
-
- )} -
-
-
- - - -
-
-

- Interactive Prompt -

-

- {questionLine} -

- - {/* Option buttons */} -
- {options.map((option) => ( - - ))} -
- - {selectedOption && ( -
-

- ✓ Claude selected option {selectedOption} -

-

- In the CLI, you would select this option interactively using arrow keys or by typing the number. -

-
- )} -
-
-
-
- ); - } - - const fileEditMatch = content.match(/The file (.+?) has been updated\./); - if (fileEditMatch) { - return ( -
-
- File updated successfully -
- -
- ); - } - - // Handle Write tool output for file creation - const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/); - if (fileCreateMatch) { - return ( -
-
- File created successfully -
- -
- ); - } - - // Special handling for Write tool - hide content if it's just the file content - if (message.toolName === 'Write' && !message.toolResult.isError) { - // For Write tool, the diff is already shown in the tool input section - // So we just show a success message here - return ( -
-
- - - - File written successfully -
-

- The file content is displayed in the diff view above -

-
- ); - } - - if (content.includes('cat -n') && content.includes('→')) { - return ( -
- - - - - View file content - -
-
- {content} -
-
-
- ); - } - - if (content.length > 300) { - return ( -
- - - - - View full output ({content.length} chars) - - - {content} - -
- ); - } - - return ( - - {content} - - ); - })()} - {permissionSuggestion && ( -
-
- - {onShowSettings && ( - - )} -
-
- Adds {permissionSuggestion.entry} to Allowed Tools. -
- {permissionGrantState === 'error' && ( -
- Unable to update permissions. Please try again. -
- )} - {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( -
- Permission saved. Retry the request to use the tool. -
- )} -
- )} -
-
- ); - })()} -
- ); - })() - ) : message.isInteractivePrompt ? ( - // Special handling for interactive prompts -
-
-
- - - -
-
-

- Interactive Prompt -

- {(() => { - const lines = message.content.split('\n').filter(line => line.trim()); - const questionLine = lines.find(line => line.includes('?')) || lines[0] || ''; - const options = []; - - // Parse the menu options - lines.forEach(line => { - // Match lines like "❯ 1. Yes" or " 2. No" - const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/); - if (optionMatch) { - const isSelected = line.includes('❯'); - options.push({ - number: optionMatch[1], - text: optionMatch[2].trim(), - isSelected - }); - } - }); - - return ( - <> -

- {questionLine} -

- - {/* Option buttons */} -
- {options.map((option) => ( - - ))} -
- -
-

- ⏳ Waiting for your response in the CLI -

-

- Please select an option in your terminal where Claude is running. -

-
- - ); - })()} -
-
-
- ) : message.isToolUse && message.toolName === 'Read' ? ( - // Simple Read tool indicator - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path) { - const filename = input.file_path.split('/').pop(); - return ( -
-
- - - - Read - -
-
- ); - } - } catch (e) { - return ( -
-
- - - - Read file -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoWrite' ? ( - // Simple TodoWrite tool indicator with tasks - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.todos && Array.isArray(input.todos)) { - return ( -
-
- - - - Update todo list -
- -
- ); - } - } catch (e) { - return ( -
-
- - - - Update todo list -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoRead' ? ( - // Simple TodoRead tool indicator -
-
- - - - Read todo list -
-
- ) : message.isThinking ? ( - /* Thinking messages - collapsible by default */ -
-
- - - - - 💭 Thinking... - -
- - {message.content} - -
-
-
- ) : ( -
- {/* Thinking accordion for reasoning */} - {showThinking && message.reasoning && ( -
- - 💭 Thinking... - -
-
- {message.reasoning} -
-
-
- )} - - {(() => { - const content = formatUsageLimitText(String(message.content || '')); - - // Detect if content is pure JSON (starts with { or [) - const trimmedContent = content.trim(); - if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && - (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { - try { - const parsed = JSON.parse(trimmedContent); - const formatted = JSON.stringify(parsed, null, 2); - - return ( -
-
- - - - JSON Response -
-
-
-                              
-                                {formatted}
-                              
-                            
-
-
- ); - } catch (e) { - // Not valid JSON, fall through to normal rendering - } - } - - // Normal rendering for non-JSON content - return message.type === 'assistant' ? ( - - {content} - - ) : ( -
- {content} -
- ); - })()} -
- )} - -
- {new Date(message.timestamp).toLocaleTimeString()} -
-
-
- )} -
- ); -}); - -// ImageAttachment component for displaying image previews -const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { - const [preview, setPreview] = useState(null); - - useEffect(() => { - const url = URL.createObjectURL(file); - setPreview(url); - return () => URL.revokeObjectURL(url); - }, [file]); - - return ( -
- {file.name} - {uploadProgress !== undefined && uploadProgress < 100 && ( -
-
{uploadProgress}%
-
- )} - {error && ( -
- - - -
- )} - -
- ); -}; - -// ChatInterface: Main chat component with Session Protection System integration -// -// Session Protection System prevents automatic project updates from interrupting active conversations: -// - onSessionActive: Called when user sends message to mark session as protected -// - onSessionInactive: Called when conversation completes/aborts to re-enable updates -// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID -// -// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. -function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, latestMessage, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { - const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); - const { t } = useTranslation('chat'); - const [input, setInput] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; - } - return ''; - }); - const [chatMessages, setChatMessages] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); - return saved ? JSON.parse(saved) : []; - } - return []; - }); - const [isLoading, setIsLoading] = useState(false); - const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); - const [isInputFocused, setIsInputFocused] = useState(false); - const [sessionMessages, setSessionMessages] = useState([]); - const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); - const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); - const [messagesOffset, setMessagesOffset] = useState(0); - const [hasMoreMessages, setHasMoreMessages] = useState(false); - const [totalMessages, setTotalMessages] = useState(0); - const MESSAGES_PER_PAGE = 20; - const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); - const [permissionMode, setPermissionMode] = useState('default'); - // In-memory queue of tool permission prompts for the current UI view. - // These are not persisted and do not survive a page refresh; introduced so - // the UI can present pending approvals while the SDK waits. - const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - const [attachedImages, setAttachedImages] = useState([]); - const [uploadingImages, setUploadingImages] = useState(new Map()); - const [imageErrors, setImageErrors] = useState(new Map()); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); - const inputContainerRef = useRef(null); - const inputHighlightRef = useRef(null); - const scrollContainerRef = useRef(null); - const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls - const isLoadingMoreRef = useRef(false); - const topLoadLockRef = useRef(false); - const pendingScrollRestoreRef = useRef(null); - // Streaming throttle buffers - const streamBufferRef = useRef(''); - const streamTimerRef = useRef(null); - // Track the session that this view expects when starting a brand‑new chat - // (prevents background sessions from streaming into a different view). - const pendingViewSessionRef = useRef(null); - const commandQueryTimerRef = useRef(null); - const [debouncedInput, setDebouncedInput] = useState(''); - const [showFileDropdown, setShowFileDropdown] = useState(false); - const [fileList, setFileList] = useState([]); - const [fileMentions, setFileMentions] = useState([]); - const [filteredFiles, setFilteredFiles] = useState([]); - const [selectedFileIndex, setSelectedFileIndex] = useState(-1); - const [cursorPosition, setCursorPosition] = useState(0); - const [atSymbolPosition, setAtSymbolPosition] = useState(-1); - const [canAbortSession, setCanAbortSession] = useState(false); - const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); - const scrollPositionRef = useRef({ height: 0, top: 0 }); - const [showCommandMenu, setShowCommandMenu] = useState(false); - const [slashCommands, setSlashCommands] = useState([]); - const [filteredCommands, setFilteredCommands] = useState([]); - const [commandQuery, setCommandQuery] = useState(''); - const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); - const [tokenBudget, setTokenBudget] = useState(null); - const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); - const [slashPosition, setSlashPosition] = useState(-1); - const [visibleMessageCount, setVisibleMessageCount] = useState(100); - const [claudeStatus, setClaudeStatus] = useState(null); - const [thinkingMode, setThinkingMode] = useState('none'); - const [provider, setProvider] = useState(() => { - return localStorage.getItem('selected-provider') || 'claude'; - }); - const [cursorModel, setCursorModel] = useState(() => { - return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; - }); - const [claudeModel, setClaudeModel] = useState(() => { - return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT; - }); - const [codexModel, setCodexModel] = useState(() => { - return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; - }); - // Track provider transitions so we only clear approvals when provider truly changes. - // This does not sync with the backend; it just prevents UI prompts from disappearing. - const lastProviderRef = useRef(provider); - - const resetStreamingState = useCallback(() => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - streamBufferRef.current = ''; - }, []); - // Load permission mode for the current session - useEffect(() => { - if (selectedSession?.id) { - const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`); - if (savedMode) { - setPermissionMode(savedMode); - } else { - setPermissionMode('default'); - } - } - }, [selectedSession?.id]); - - // When selecting a session from Sidebar, auto-switch provider to match session's origin - useEffect(() => { - if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) { - setProvider(selectedSession.__provider); - localStorage.setItem('selected-provider', selectedSession.__provider); - } - }, [selectedSession]); - - // Clear pending permission prompts when switching providers; filter when switching sessions. - // This does not preserve prompts across provider changes; it exists to keep the - // Claude approval flow intact while preventing prompts from a different provider. - useEffect(() => { - if (lastProviderRef.current !== provider) { - setPendingPermissionRequests([]); - lastProviderRef.current = provider; - } - }, [provider]); - - // When the selected session changes, drop prompts that belong to other sessions. - // This does not attempt to migrate prompts across sessions; it only filters, - // introduced so the UI does not show approvals for a session the user is no longer viewing. - useEffect(() => { - setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id)); - }, [selectedSession?.id]); - - // Load Cursor default model from config - useEffect(() => { - if (provider === 'cursor') { - authenticatedFetch('/api/cursor/config') - .then(res => res.json()) - .then(data => { - if (data.success && data.config?.model?.modelId) { - // Use the model from config directly - const modelId = data.config.model.modelId; - if (!localStorage.getItem('cursor-model')) { - setCursorModel(modelId); - } - } - }) - .catch(err => console.error('Error loading Cursor config:', err)); - } - }, [provider]); - - // Fetch slash commands on mount and when project changes - useEffect(() => { - const fetchCommands = async () => { - if (!selectedProject) return; - - try { - const response = await authenticatedFetch('/api/commands/list', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - projectPath: selectedProject.path - }) - }); - - if (!response.ok) { - throw new Error('Failed to fetch commands'); - } - - const data = await response.json(); - - // Combine built-in and custom commands - const allCommands = [ - ...(data.builtIn || []).map(cmd => ({ ...cmd, type: 'built-in' })), - ...(data.custom || []).map(cmd => ({ ...cmd, type: 'custom' })) - ]; - - setSlashCommands(allCommands); - - // Load command history from localStorage - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - if (history) { - try { - const parsedHistory = JSON.parse(history); - // Sort commands by usage frequency - const sortedCommands = allCommands.sort((a, b) => { - const aCount = parsedHistory[a.name] || 0; - const bCount = parsedHistory[b.name] || 0; - return bCount - aCount; - }); - setSlashCommands(sortedCommands); - } catch (e) { - console.error('Error parsing command history:', e); - } - } - } catch (error) { - console.error('Error fetching slash commands:', error); - setSlashCommands([]); - } - }; - - fetchCommands(); - }, [selectedProject]); - - // Create Fuse instance for fuzzy search - const fuse = useMemo(() => { - if (!slashCommands.length) return null; - - return new Fuse(slashCommands, { - keys: [ - { name: 'name', weight: 2 }, - { name: 'description', weight: 1 } - ], - threshold: 0.4, - includeScore: true, - minMatchCharLength: 1 - }); - }, [slashCommands]); - - // Filter commands based on query - useEffect(() => { - if (!commandQuery) { - setFilteredCommands(slashCommands); - return; - } - - if (!fuse) { - setFilteredCommands([]); - return; - } - - const results = fuse.search(commandQuery); - setFilteredCommands(results.map(result => result.item)); - }, [commandQuery, slashCommands, fuse]); - - // Calculate frequently used commands - const frequentCommands = useMemo(() => { - if (!selectedProject || slashCommands.length === 0) return []; - - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - - if (!history) return []; - - try { - const parsedHistory = JSON.parse(history); - - // Sort commands by usage count - const commandsWithUsage = slashCommands - .map(cmd => ({ - ...cmd, - usageCount: parsedHistory[cmd.name] || 0 - })) - .filter(cmd => cmd.usageCount > 0) - .sort((a, b) => b.usageCount - a.usageCount) - .slice(0, 5); // Top 5 most used - - return commandsWithUsage; - } catch (e) { - console.error('Error parsing command history:', e); - return []; - } - }, [selectedProject, slashCommands]); - - // Command selection callback with history tracking - const handleCommandSelect = useCallback((command, index, isHover) => { - if (!command || !selectedProject) return; - - // If hovering, just update the selected index - if (isHover) { - setSelectedCommandIndex(index); - return; - } - - // Update command history - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - let parsedHistory = {}; - - try { - parsedHistory = history ? JSON.parse(history) : {}; - } catch (e) { - console.error('Error parsing command history:', e); - } - - parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; - safeLocalStorage.setItem(historyKey, JSON.stringify(parsedHistory)); - - // Execute the command - executeCommand(command); - }, [selectedProject]); - - // Execute a command - const handleBuiltInCommand = useCallback((result) => { - const { action, data } = result; - - switch (action) { - case 'clear': - // Clear conversation history - setChatMessages([]); - setSessionMessages([]); - break; - - case 'help': - // Show help content - setChatMessages(prev => [...prev, { - role: 'assistant', - content: data.content, - timestamp: Date.now() - }]); - break; - - case 'model': - // Show model information - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, - timestamp: Date.now() - }]); - break; - - case 'cost': { - const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; - setChatMessages(prev => [...prev, { role: 'assistant', content: costMessage, timestamp: Date.now() }]); - break; - } - - case 'status': { - const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; - setChatMessages(prev => [...prev, { role: 'assistant', content: statusMessage, timestamp: Date.now() }]); - break; - } - case 'memory': - // Show memory file info - if (data.error) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now() - }]); - } else { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `📝 ${data.message}\n\nPath: \`${data.path}\``, - timestamp: Date.now() - }]); - // Optionally open file in editor - if (data.exists && onFileOpen) { - onFileOpen(data.path); - } - } - break; - - case 'config': - // Open settings - if (onShowSettings) { - onShowSettings(); - } - break; - - case 'rewind': - // Rewind conversation - if (data.error) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now() - }]); - } else { - // Remove last N messages - setChatMessages(prev => prev.slice(0, -data.steps * 2)); // Remove user + assistant pairs - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⏪ ${data.message}`, - timestamp: Date.now() - }]); - } - break; - - default: - console.warn('Unknown built-in command action:', action); - } - }, [onFileOpen, onShowSettings]); - - // Ref to store handleSubmit so we can call it from handleCustomCommand - const handleSubmitRef = useRef(null); - - // Handle custom command execution - const handleCustomCommand = useCallback(async (result, args) => { - const { content, hasBashCommands, hasFileIncludes } = result; - - // Show confirmation for bash commands - if (hasBashCommands) { - const confirmed = window.confirm( - 'This command contains bash commands that will be executed. Do you want to proceed?' - ); - if (!confirmed) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: '❌ Command execution cancelled', - timestamp: Date.now() - }]); - return; - } - } - - // 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); - }, []); - const executeCommand = useCallback(async (command) => { - if (!command || !selectedProject) return; - - try { - // Parse command and arguments from current input - const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`)); - const args = commandMatch && commandMatch[1] - ? commandMatch[1].trim().split(/\s+/) - : []; - - // Prepare context for command execution - const context = { - projectPath: selectedProject.path, - projectName: selectedProject.name, - sessionId: currentSessionId, - provider, - model: provider === 'cursor' ? cursorModel : claudeModel, - tokenUsage: tokenBudget - }; - - // Call the execute endpoint - const response = await authenticatedFetch('/api/commands/execute', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - commandName: command.name, - commandPath: command.path, - args, - context - }) - }); - - if (!response.ok) { - throw new Error('Failed to execute command'); - } - - const result = await response.json(); - - // Handle built-in commands - if (result.type === 'builtin') { - handleBuiltInCommand(result); - } else if (result.type === 'custom') { - // Handle custom commands - inject as system message - await handleCustomCommand(result, args); - } - - // Clear the input after successful execution - setInput(''); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - - } catch (error) { - console.error('Error executing command:', error); - // Show error message to user - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `Error executing command: ${error.message}`, - timestamp: Date.now() - }]); - } - }, [input, selectedProject, currentSessionId, provider, cursorModel, tokenBudget]); - - // Handle built-in command actions - - - // Memoized diff calculation to prevent recalculating on every render - const createDiff = useMemo(() => { - const cache = new Map(); - return (oldStr, newStr) => { - const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`; - if (cache.has(key)) { - return cache.get(key); - } - - const result = calculateDiff(oldStr, newStr); - cache.set(key, result); - if (cache.size > 100) { - const firstKey = cache.keys().next().value; - cache.delete(firstKey); - } - return result; - }; - }, []); - - // Load session messages from API with pagination - const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false, provider = 'claude') => { - if (!projectName || !sessionId) return []; - - const isInitialLoad = !loadMore; - if (isInitialLoad) { - setIsLoadingSessionMessages(true); - } else { - setIsLoadingMoreMessages(true); - } - - try { - const currentOffset = loadMore ? messagesOffset : 0; - const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider); - if (!response.ok) { - throw new Error('Failed to load session messages'); - } - const data = await response.json(); - - // Extract token usage if present (Codex includes it in messages response) - if (isInitialLoad && data.tokenUsage) { - setTokenBudget(data.tokenUsage); - } - - // Handle paginated response - if (data.hasMore !== undefined) { - setHasMoreMessages(data.hasMore); - setTotalMessages(data.total); - setMessagesOffset(currentOffset + (data.messages?.length || 0)); - return data.messages || []; - } else { - // Backward compatibility for non-paginated response - const messages = data.messages || []; - setHasMoreMessages(false); - setTotalMessages(messages.length); - return messages; - } - } catch (error) { - console.error('Error loading session messages:', error); - return []; - } finally { - if (isInitialLoad) { - setIsLoadingSessionMessages(false); - } else { - setIsLoadingMoreMessages(false); - } - } - }, [messagesOffset]); - - // Load Cursor session messages from SQLite via backend - const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => { - if (!projectPath || !sessionId) return []; - setIsLoadingSessionMessages(true); - try { - const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; - const res = await authenticatedFetch(url); - if (!res.ok) return []; - const data = await res.json(); - const blobs = data?.session?.messages || []; - const converted = []; - const toolUseMap = {}; // Map to store tool uses by ID for linking results - - // First pass: process all messages maintaining order - for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) { - const blob = blobs[blobIdx]; - const content = blob.content; - let text = ''; - let role = 'assistant'; - let reasoningText = null; // Move to outer scope - try { - // Handle different Cursor message formats - if (content?.role && content?.content) { - // Direct format: {"role":"user","content":[{"type":"text","text":"..."}]} - // Skip system messages - if (content.role === 'system') { - continue; - } - - // Handle tool messages - if (content.role === 'tool') { - // Tool result format - find the matching tool use message and update it - if (Array.isArray(content.content)) { - for (const item of content.content) { - if (item?.type === 'tool-result') { - // Map ApplyPatch to Edit for consistency - let toolName = item.toolName || 'Unknown Tool'; - if (toolName === 'ApplyPatch') { - toolName = 'Edit'; - } - const toolCallId = item.toolCallId || content.id; - const result = item.result || ''; - - // Store the tool result to be linked later - if (toolUseMap[toolCallId]) { - toolUseMap[toolCallId].toolResult = { - content: result, - isError: false - }; - } else { - // No matching tool use found, create a standalone result message - converted.push({ - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolCallId, - toolInput: null, - toolResult: { - content: result, - isError: false - } - }); - } - } - } - } - continue; // Don't add tool messages as regular messages - } else { - // User or assistant messages - role = content.role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content.content)) { - // Extract text, reasoning, and tool calls from content array - const textParts = []; - - for (const part of content.content) { - if (part?.type === 'text' && part?.text) { - textParts.push(decodeHtmlEntities(part.text)); - } else if (part?.type === 'reasoning' && part?.text) { - // Handle reasoning type - will be displayed in a collapsible section - reasoningText = decodeHtmlEntities(part.text); - } else if (part?.type === 'tool-call') { - // First, add any text/reasoning we've collected so far as a message - if (textParts.length > 0 || reasoningText) { - converted.push({ - type: role, - content: textParts.join('\n'), - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - textParts.length = 0; - reasoningText = null; - } - - // Tool call in assistant message - format like Claude Code - // Map ApplyPatch to Edit for consistency with Claude Code - let toolName = part.toolName || 'Unknown Tool'; - if (toolName === 'ApplyPatch') { - toolName = 'Edit'; - } - const toolId = part.toolCallId || `tool_${blobIdx}`; - - // Create a tool use message with Claude Code format - // Map Cursor args format to Claude Code format - let toolInput = part.args; - - if (toolName === 'Edit' && part.args) { - // ApplyPatch uses 'patch' format, convert to Edit format - if (part.args.patch) { - // Parse the patch to extract old and new content - const patchLines = part.args.patch.split('\n'); - let oldLines = []; - let newLines = []; - let inPatch = false; - - for (const line of patchLines) { - if (line.startsWith('@@')) { - inPatch = true; - } else if (inPatch) { - if (line.startsWith('-')) { - oldLines.push(line.substring(1)); - } else if (line.startsWith('+')) { - newLines.push(line.substring(1)); - } else if (line.startsWith(' ')) { - // Context line - add to both - oldLines.push(line.substring(1)); - newLines.push(line.substring(1)); - } - } - } - - const filePath = part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath, - old_string: oldLines.join('\n') || part.args.patch, - new_string: newLines.join('\n') || part.args.patch - }; - } else { - // Direct edit format - toolInput = part.args; - } - } else if (toolName === 'Read' && part.args) { - // Map 'path' to 'file_path' - // Convert relative path to absolute if needed - const filePath = part.args.path || part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath - }; - } else if (toolName === 'Write' && part.args) { - // Map fields for Write tool - const filePath = part.args.path || part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath, - content: part.args.contents || part.args.content - }; - } - - const toolMessage = { - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolId, - toolInput: toolInput ? JSON.stringify(toolInput) : null, - toolResult: null // Will be filled when we get the tool result - }; - converted.push(toolMessage); - toolUseMap[toolId] = toolMessage; // Store for linking results - } else if (part?.type === 'tool_use') { - // Old format support - if (textParts.length > 0 || reasoningText) { - converted.push({ - type: role, - content: textParts.join('\n'), - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - textParts.length = 0; - reasoningText = null; - } - - const toolName = part.name || 'Unknown Tool'; - const toolId = part.id || `tool_${blobIdx}`; - - const toolMessage = { - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolId, - toolInput: part.input ? JSON.stringify(part.input) : null, - toolResult: null - }; - converted.push(toolMessage); - toolUseMap[toolId] = toolMessage; - } else if (typeof part === 'string') { - textParts.push(part); - } - } - - // Add any remaining text/reasoning - if (textParts.length > 0) { - text = textParts.join('\n'); - if (reasoningText && !text) { - // Just reasoning, no text - converted.push({ - type: role, - content: '', - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - text = ''; // Clear to avoid duplicate - } - } else { - text = ''; - } - } else if (typeof content.content === 'string') { - text = content.content; - } - } - } else if (content?.message?.role && content?.message?.content) { - // Nested message format - if (content.message.role === 'system') { - continue; - } - role = content.message.role === 'user' ? 'user' : 'assistant'; - if (Array.isArray(content.message.content)) { - text = content.message.content - .map(p => (typeof p === 'string' ? p : (p?.text || ''))) - .filter(Boolean) - .join('\n'); - } else if (typeof content.message.content === 'string') { - text = content.message.content; - } - } - } catch (e) { - console.log('Error parsing blob content:', e); - } - if (text && text.trim()) { - const message = { - type: role, - content: text, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }; - - // Add reasoning if we have it - if (reasoningText) { - message.reasoning = reasoningText; - } - - converted.push(message); - } - } - - // Sort messages by sequence/rowid to maintain chronological order - converted.sort((a, b) => { - // First sort by sequence if available (clean 1,2,3... numbering) - if (a.sequence !== undefined && b.sequence !== undefined) { - return a.sequence - b.sequence; - } - // Then try rowid (original SQLite row IDs) - if (a.rowid !== undefined && b.rowid !== undefined) { - return a.rowid - b.rowid; - } - // Fallback to timestamp - return new Date(a.timestamp) - new Date(b.timestamp); - }); - - return converted; - } catch (e) { - console.error('Error loading Cursor session messages:', e); - return []; - } finally { - setIsLoadingSessionMessages(false); - } - }, []); - - // Actual diff calculation function - const calculateDiff = (oldStr, newStr) => { - const oldLines = oldStr.split('\n'); - const newLines = newStr.split('\n'); - - // Simple diff algorithm - find common lines and differences - const diffLines = []; - let oldIndex = 0; - let newIndex = 0; - - while (oldIndex < oldLines.length || newIndex < newLines.length) { - const oldLine = oldLines[oldIndex]; - const newLine = newLines[newIndex]; - - if (oldIndex >= oldLines.length) { - // Only new lines remaining - diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); - newIndex++; - } else if (newIndex >= newLines.length) { - // Only old lines remaining - diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); - oldIndex++; - } else if (oldLine === newLine) { - // Lines are the same - skip in diff view (or show as context) - oldIndex++; - newIndex++; - } else { - // Lines are different - diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); - diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); - oldIndex++; - newIndex++; - } - } - - return diffLines; - }; - - const convertSessionMessages = (rawMessages) => { - const converted = []; - const toolResults = new Map(); // Map tool_use_id to tool result - - // First pass: collect all tool results - for (const msg of rawMessages) { - if (msg.message?.role === 'user' && Array.isArray(msg.message?.content)) { - for (const part of msg.message.content) { - if (part.type === 'tool_result') { - toolResults.set(part.tool_use_id, { - content: part.content, - isError: part.is_error, - timestamp: new Date(msg.timestamp || Date.now()), - // Extract structured tool result data (e.g., for Grep, Glob) - toolUseResult: msg.toolUseResult || null - }); - } - } - } - } - - // Second pass: process messages and attach tool results to tool uses - for (const msg of rawMessages) { - // Handle user messages - if (msg.message?.role === 'user' && msg.message?.content) { - let content = ''; - let messageType = 'user'; - - if (Array.isArray(msg.message.content)) { - // Handle array content, but skip tool results (they're attached to tool uses) - const textParts = []; - - for (const part of msg.message.content) { - if (part.type === 'text') { - textParts.push(decodeHtmlEntities(part.text)); - } - // Skip tool_result parts - they're handled in the first pass - } - - content = textParts.join('\n'); - } else if (typeof msg.message.content === 'string') { - content = decodeHtmlEntities(msg.message.content); - } else { - content = decodeHtmlEntities(String(msg.message.content)); - } - - // Skip command messages, system messages, and empty content - const shouldSkip = !content || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('Caveat:') || - content.startsWith('This session is being continued from a previous') || - content.startsWith('[Request interrupted'); - - if (!shouldSkip) { - // Unescape with math formula protection - content = unescapeWithMathProtection(content); - converted.push({ - type: messageType, - content: content, - timestamp: msg.timestamp || new Date().toISOString() - }); - } - } - - // Handle thinking messages (Codex reasoning) - else if (msg.type === 'thinking' && msg.message?.content) { - converted.push({ - type: 'assistant', - content: unescapeWithMathProtection(msg.message.content), - timestamp: msg.timestamp || new Date().toISOString(), - isThinking: true - }); - } - - // Handle tool_use messages (Codex function calls) - else if (msg.type === 'tool_use' && msg.toolName) { - converted.push({ - type: 'assistant', - content: '', - timestamp: msg.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: msg.toolName, - toolInput: msg.toolInput || '', - toolCallId: msg.toolCallId - }); - } - - // Handle tool_result messages (Codex function outputs) - else if (msg.type === 'tool_result') { - // Find the matching tool_use by callId, or the last tool_use without a result - for (let i = converted.length - 1; i >= 0; i--) { - if (converted[i].isToolUse && !converted[i].toolResult) { - if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) { - converted[i].toolResult = { - content: msg.output || '', - isError: false - }; - break; - } - } - } - } - - // Handle assistant messages - else if (msg.message?.role === 'assistant' && msg.message?.content) { - if (Array.isArray(msg.message.content)) { - for (const part of msg.message.content) { - if (part.type === 'text') { - // Unescape with math formula protection - let text = part.text; - if (typeof text === 'string') { - text = unescapeWithMathProtection(text); - } - converted.push({ - type: 'assistant', - content: text, - timestamp: msg.timestamp || new Date().toISOString() - }); - } else if (part.type === 'tool_use') { - // Get the corresponding tool result - const toolResult = toolResults.get(part.id); - - converted.push({ - type: 'assistant', - content: '', - timestamp: msg.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: part.name, - toolInput: JSON.stringify(part.input), - toolResult: toolResult ? { - content: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content), - isError: toolResult.isError, - toolUseResult: toolResult.toolUseResult - } : null, - toolError: toolResult?.isError || false, - toolResultTimestamp: toolResult?.timestamp || new Date() - }); - } - } - } else if (typeof msg.message.content === 'string') { - // Unescape with math formula protection - let text = msg.message.content; - text = unescapeWithMathProtection(text); - converted.push({ - type: 'assistant', - content: text, - timestamp: msg.timestamp || new Date().toISOString() - }); - } - } - } - - return converted; - }; - - // Memoize expensive convertSessionMessages operation - const convertedMessages = useMemo(() => { - return convertSessionMessages(sessionMessages); - }, [sessionMessages]); - - // Note: Token budgets are not saved to JSONL files, only sent via WebSocket - // So we don't try to extract them from loaded sessionMessages - - // Define scroll functions early to avoid hoisting issues in useEffect dependencies - const scrollToBottom = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; - // Don't reset isUserScrolledUp here - let the scroll handler manage it - // This prevents fighting with user's scroll position during streaming - } - }, []); - - // Check if user is near the bottom of the scroll container - const isNearBottom = useCallback(() => { - if (!scrollContainerRef.current) return false; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - // Consider "near bottom" if within 50px of the bottom - return scrollHeight - scrollTop - clientHeight < 50; - }, []); - - const loadOlderMessages = useCallback(async (container) => { - if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false; - if (!hasMoreMessages || !selectedSession || !selectedProject) return false; - - const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') return false; - - isLoadingMoreRef.current = true; - const previousScrollHeight = container.scrollHeight; - const previousScrollTop = container.scrollTop; - - try { - const moreMessages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - true, - sessionProvider - ); - - if (moreMessages.length > 0) { - pendingScrollRestoreRef.current = { - height: previousScrollHeight, - top: previousScrollTop - }; - // Prepend new messages to the existing ones - setSessionMessages(prev => [...moreMessages, ...prev]); - } - return true; - } finally { - isLoadingMoreRef.current = false; - } - }, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); - - // Handle scroll events to detect when user manually scrolls up and load more messages - const handleScroll = useCallback(async () => { - if (scrollContainerRef.current) { - const container = scrollContainerRef.current; - const nearBottom = isNearBottom(); - setIsUserScrolledUp(!nearBottom); - - // Check if we should load more messages (scrolled near top) - const scrolledNearTop = container.scrollTop < 100; - if (!scrolledNearTop) { - topLoadLockRef.current = false; - } else if (!topLoadLockRef.current) { - const didLoad = await loadOlderMessages(container); - if (didLoad) { - topLoadLockRef.current = true; - } - } - } - }, [isNearBottom, loadOlderMessages]); - - // Restore scroll position after paginated messages render - useLayoutEffect(() => { - if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; - - const { height, top } = pendingScrollRestoreRef.current; - const container = scrollContainerRef.current; - const newScrollHeight = container.scrollHeight; - const scrollDiff = newScrollHeight - height; - - container.scrollTop = top + Math.max(scrollDiff, 0); - pendingScrollRestoreRef.current = null; - }, [chatMessages.length]); - - useEffect(() => { - // Load session messages when session changes - const loadMessages = async () => { - if (selectedSession && selectedProject) { - const provider = localStorage.getItem('selected-provider') || 'claude'; - - // Mark that we're loading a session to prevent multiple scroll triggers - isLoadingSessionRef.current = true; - - // Only reset state if the session ID actually changed (not initial load) - const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; - - if (sessionChanged) { - if (!isSystemSessionChange) { - // Clear any streaming leftovers from the previous session - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - } - // Reset pagination state when switching sessions - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - // Reset token budget when switching sessions - // It will update when user sends a message and receives new budget from WebSocket - setTokenBudget(null); - // Reset loading state when switching sessions (unless the new session is processing) - // The restore effect will set it back to true if needed - setIsLoading(false); - - // Check if the session is currently processing on the backend - if (ws && sendMessage) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider - }); - } - } else if (currentSessionId === null) { - // Initial load - reset pagination but not token budget - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - - // Check if the session is currently processing on the backend - if (ws && sendMessage) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider - }); - } - } - - if (provider === 'cursor') { - // For Cursor, set the session ID for resuming - setCurrentSessionId(selectedSession.id); - sessionStorage.setItem('cursorSessionId', selectedSession.id); - - // Only load messages from SQLite if this is NOT a system-initiated session change - // For system-initiated changes, preserve existing messages - if (!isSystemSessionChange) { - // Load historical messages for Cursor session from SQLite - const projectPath = selectedProject.fullPath || selectedProject.path; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - } else { - // Reset the flag after handling system session change - setIsSystemSessionChange(false); - } - } else { - // For Claude, load messages normally with pagination - setCurrentSessionId(selectedSession.id); - - // Only load messages from API if this is a user-initiated session change - // For system-initiated changes, preserve existing messages and rely on WebSocket - if (!isSystemSessionChange) { - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); - setSessionMessages(messages); - // convertedMessages will be automatically updated via useMemo - // Scroll will be handled by the main scroll useEffect after messages are rendered - } else { - // Reset the flag after handling system session change - setIsSystemSessionChange(false); - } - } - } else { - // New session view (no selected session) - always reset UI state - if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); - } - setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - setTokenBudget(null); - } - - // Mark loading as complete after messages are set - // Use setTimeout to ensure state updates and DOM rendering are complete - setTimeout(() => { - isLoadingSessionRef.current = false; - }, 250); - }; - - loadMessages(); - }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]); - - // External Message Update Handler: Reload messages when external CLI modifies current session - // This triggers when App.jsx detects a JSONL file change for the currently-viewed session - // Only reloads if the session is NOT active (respecting Session Protection System) - useEffect(() => { - if (externalMessageUpdate > 0 && selectedSession && selectedProject) { - const reloadExternalMessages = async () => { - try { - const provider = localStorage.getItem('selected-provider') || 'claude'; - - if (provider === 'cursor') { - // Reload Cursor messages from SQLite - const projectPath = selectedProject.fullPath || selectedProject.path; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - } else { - // Reload Claude/Codex messages from API/JSONL - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); - setSessionMessages(messages); - // convertedMessages will be automatically updated via useMemo - - // Smart scroll behavior: only auto-scroll if user is near bottom - const shouldAutoScroll = autoScrollToBottom && isNearBottom(); - if (shouldAutoScroll) { - setTimeout(() => scrollToBottom(), 200); - } - // If user scrolled up, preserve their position (they're reading history) - } - } catch (error) { - console.error('Error reloading messages from external update:', error); - } - }; - - reloadExternalMessages(); - } - }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); - - // When the user navigates to a specific session, clear any pending "new session" marker. - useEffect(() => { - if (selectedSession?.id) { - pendingViewSessionRef.current = null; - } - }, [selectedSession?.id]); - - // Update chatMessages when convertedMessages changes - useEffect(() => { - if (sessionMessages.length > 0) { - setChatMessages(convertedMessages); - } - }, [convertedMessages, sessionMessages]); - - // Notify parent when input focus changes - useEffect(() => { - if (onInputFocusChange) { - onInputFocusChange(isInputFocused); - } - }, [isInputFocused, onInputFocusChange]); - - // Persist input draft to localStorage - useEffect(() => { - if (selectedProject && input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); - } else if (selectedProject && input === '') { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); - } - }, [input, selectedProject]); - - // Persist chat messages to localStorage - useEffect(() => { - if (selectedProject && chatMessages.length > 0) { - safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); - } - }, [chatMessages, selectedProject]); - - // Load saved state when project changes (but don't interfere with session loading) - useEffect(() => { - if (selectedProject) { - // Always load saved input draft for the project - const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; - if (savedInput !== input) { - setInput(savedInput); - } - } - }, [selectedProject?.name]); - - // Track processing state: notify parent when isLoading becomes true - // Note: onSessionNotProcessing is called directly in completion message handlers - useEffect(() => { - if (currentSessionId && isLoading && onSessionProcessing) { - onSessionProcessing(currentSessionId); - } - }, [isLoading, currentSessionId, onSessionProcessing]); - - // Restore processing state when switching to a processing session - useEffect(() => { - if (currentSessionId && processingSessions) { - const shouldBeProcessing = processingSessions.has(currentSessionId); - if (shouldBeProcessing && !isLoading) { - setIsLoading(true); - setCanAbortSession(true); // Assume processing sessions can be aborted - } - } - }, [currentSessionId, processingSessions]); - - useEffect(() => { - // Handle WebSocket messages - if (latestMessage) { - const messageData = latestMessage.data?.message || latestMessage.data; - - // Filter messages by session ID to prevent cross-session interference - // Skip filtering for global messages that apply to all sessions - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; - const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); - const lifecycleMessageTypes = new Set([ - 'claude-complete', - 'codex-complete', - 'cursor-result', - 'session-aborted', - 'claude-error', - 'cursor-error', - 'codex-error' - ]); - - const isClaudeSystemInit = latestMessage.type === 'claude-response' && - messageData && - messageData.type === 'system' && - messageData.subtype === 'init'; - const isCursorSystemInit = latestMessage.type === 'cursor-system' && - latestMessage.data && - latestMessage.data.type === 'system' && - latestMessage.data.subtype === 'init'; - - const systemInitSessionId = isClaudeSystemInit - ? messageData?.session_id - : isCursorSystemInit - ? latestMessage.data?.session_id - : null; - - const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; - const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); - const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView; - const isUnscopedError = !latestMessage.sessionId && - pendingViewSessionRef.current && - !pendingViewSessionRef.current.sessionId && - (latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error'); - - const handleBackgroundLifecycle = (sessionId) => { - if (!sessionId) return; - if (onSessionInactive) { - onSessionInactive(sessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(sessionId); - } - }; - - if (!shouldBypassSessionFilter) { - if (!activeViewSessionId) { - // No session in view; ignore session-scoped traffic. - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - if (!isUnscopedError) { - return; - } - } - if (!latestMessage.sessionId && !isUnscopedError) { - // Drop unscoped messages to prevent cross-session bleed. - return; - } - if (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - // Message is for a different session, ignore it - console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId); - return; - } - } - - switch (latestMessage.type) { - case 'session-created': - // New session created by Claude CLI - we receive the real session ID here - // Store it temporarily until conversation completes (prevents premature session association) - if (latestMessage.sessionId && !currentSessionId) { - sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { - pendingViewSessionRef.current.sessionId = latestMessage.sessionId; - } - - // Mark as system change to prevent clearing messages when session ID updates - setIsSystemSessionChange(true); - - // Session Protection: Replace temporary "new-session-*" identifier with real session ID - // This maintains protection continuity - no gap between temp ID and real ID - // The temporary session is removed and real session is marked as active - if (onReplaceTemporarySession) { - onReplaceTemporarySession(latestMessage.sessionId); - } - - // Attach the real session ID to any pending permission requests so they - // do not disappear during the "new-session -> real-session" transition. - // This does not create or auto-approve requests; it only keeps UI state aligned. - setPendingPermissionRequests(prev => prev.map(req => ( - req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId } - ))); - } - break; - - case 'token-budget': - // Use token budget from WebSocket for active sessions - if (latestMessage.data) { - setTokenBudget(latestMessage.data); - } - break; - - case 'claude-response': - - // Handle Cursor streaming format (content_block_delta / content_block_stop) - if (messageData && typeof messageData === 'object' && messageData.type) { - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { - // Decode HTML entities and buffer deltas - const decodedText = decodeHtmlEntities(messageData.delta.text); - streamBufferRef.current += decodedText; - if (!streamTimerRef.current) { - streamTimerRef.current = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = (last.content || '') + chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - return; - } - if (messageData.type === 'content_block_stop') { - // Flush any buffered text and mark streaming message complete - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - if (chunk) { - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = (last.content || '') + chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - } - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && last.isStreaming) { - last.isStreaming = false; - } - return updated; - }); - return; - } - } - - // Handle Claude CLI session duplication bug workaround: - // When resuming a session, Claude CLI creates a new session instead of resuming. - // We detect this by checking for system/init messages with session_id that differs - // from our current session. When found, we need to switch the user to the new session. - // This works exactly like new session detection - preserve messages during navigation. - if (latestMessage.data.type === 'system' && - latestMessage.data.subtype === 'init' && - latestMessage.data.session_id && - currentSessionId && - latestMessage.data.session_id !== currentSessionId && - isSystemInitForView) { - - console.log('🔄 Claude CLI session duplication detected:', { - originalSession: currentSessionId, - newSession: latestMessage.data.session_id - }); - - // Mark this as a system-initiated session change to preserve messages - // This works exactly like new session init - messages stay visible during navigation - setIsSystemSessionChange(true); - - // Switch to the new session using React Router navigation - // This triggers the session loading logic in App.jsx without a page reload - if (onNavigateToSession) { - onNavigateToSession(latestMessage.data.session_id); - } - return; // Don't process the message further, let the navigation handle it - } - - // Handle system/init for new sessions (when currentSessionId is null) - if (latestMessage.data.type === 'system' && - latestMessage.data.subtype === 'init' && - latestMessage.data.session_id && - !currentSessionId && - isSystemInitForView) { - - console.log('🔄 New session init detected:', { - newSession: latestMessage.data.session_id - }); - - // Mark this as a system-initiated session change to preserve messages - setIsSystemSessionChange(true); - - // Switch to the new session - if (onNavigateToSession) { - onNavigateToSession(latestMessage.data.session_id); - } - return; // Don't process the message further, let the navigation handle it - } - - // For system/init messages that match current session, just ignore them - if (latestMessage.data.type === 'system' && - latestMessage.data.subtype === 'init' && - latestMessage.data.session_id && - currentSessionId && - latestMessage.data.session_id === currentSessionId && - isSystemInitForView) { - console.log('🔄 System init message for current session, ignoring'); - return; // Don't process the message further - } - - // Handle different types of content in the response - if (Array.isArray(messageData.content)) { - for (const part of messageData.content) { - if (part.type === 'tool_use') { - // Add tool use message - const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: part.name, - toolInput: toolInput, - toolId: part.id, - toolResult: null // Will be updated when result comes in - }]); - } else if (part.type === 'text' && part.text?.trim()) { - // Decode HTML entities and normalize usage limit message to local time - let content = decodeHtmlEntities(part.text); - content = formatUsageLimitText(content); - - // Add regular text message - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - } - } else if (typeof messageData.content === 'string' && messageData.content.trim()) { - // Decode HTML entities and normalize usage limit message to local time - let content = decodeHtmlEntities(messageData.content); - content = formatUsageLimitText(content); - - // Add regular text message - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - - // Handle tool results from user messages (these come separately) - if (messageData.role === 'user' && Array.isArray(messageData.content)) { - for (const part of messageData.content) { - if (part.type === 'tool_result') { - // Find the corresponding tool use and update it with the result - setChatMessages(prev => prev.map(msg => { - if (msg.isToolUse && msg.toolId === part.tool_use_id) { - return { - ...msg, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date() - } - }; - } - return msg; - })); - } - } - } - break; - - case 'claude-output': - { - const cleaned = String(latestMessage.data || ''); - if (cleaned.trim()) { - streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned); - if (!streamTimerRef.current) { - streamTimerRef.current = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = last.content ? `${last.content}\n${chunk}` : chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - } - } - break; - case 'claude-interactive-prompt': - // Handle interactive prompts from CLI - setChatMessages(prev => [...prev, { - type: 'assistant', - content: latestMessage.data, - timestamp: new Date(), - isInteractivePrompt: true - }]); - break; - - case 'claude-permission-request': { - // Receive a tool approval request from the backend and surface it in the UI. - // This does not approve anything automatically; it only queues a prompt, - // introduced so the user can decide before the SDK continues. - if (provider !== 'claude' || !latestMessage.requestId) { - break; - } - - setPendingPermissionRequests(prev => { - if (prev.some(req => req.requestId === latestMessage.requestId)) { - return prev; - } - return [ - ...prev, - { - requestId: latestMessage.requestId, - toolName: latestMessage.toolName || 'UnknownTool', - input: latestMessage.input, - context: latestMessage.context, - sessionId: latestMessage.sessionId || null, - receivedAt: new Date() - } - ]; - }); - - // Keep the session in a "waiting" state while approval is pending. - // This does not resume the run; it only updates the UI status so the - // user knows Claude is blocked on a decision. - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Waiting for permission', - tokens: 0, - can_interrupt: true - }); - break; - } - - case 'claude-permission-cancelled': { - // Backend cancelled the approval (timeout or SDK cancel); remove the banner. - // We currently do not show a user-facing warning here; this is intentional - // to avoid noisy alerts when the SDK cancels in the background. - if (!latestMessage.requestId) { - break; - } - setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId)); - break; - } - - case 'claude-error': - setChatMessages(prev => [...prev, { - type: 'error', - content: `Error: ${latestMessage.error}`, - timestamp: new Date() - }]); - break; - - case 'cursor-system': - // Handle Cursor system/init messages similar to Claude - try { - const cdata = latestMessage.data; - if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) { - if (!isSystemInitForView) { - return; - } - // If we already have a session and this differs, switch (duplication/redirect) - if (currentSessionId && cdata.session_id !== currentSessionId) { - console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id }); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(cdata.session_id); - } - return; - } - // If we don't yet have a session, adopt this one - if (!currentSessionId) { - console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id }); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(cdata.session_id); - } - return; - } - } - // For other cursor-system messages, avoid dumping raw objects to chat - } catch (e) { - console.warn('Error handling cursor-system message:', e); - } - break; - - case 'cursor-user': - // Handle Cursor user messages (usually echoes) - // Don't add user messages as they're already shown from input - break; - - case 'cursor-tool-use': - // Handle Cursor tool use messages - setChatMessages(prev => [...prev, { - type: 'assistant', - content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`, - timestamp: new Date(), - isToolUse: true, - toolName: latestMessage.tool, - toolInput: latestMessage.input - }]); - break; - - case 'cursor-error': - // Show Cursor errors as error messages in chat - setChatMessages(prev => [...prev, { - type: 'error', - content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, - timestamp: new Date() - }]); - break; - - case 'cursor-result': - // Get session ID from message or fall back to current session - const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - - // Only update UI state if this is the current session - if (cursorCompletedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the completed session as inactive and not processing - if (cursorCompletedSessionId) { - if (onSessionInactive) { - onSessionInactive(cursorCompletedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(cursorCompletedSessionId); - } - } - - // Only process result for current session - if (cursorCompletedSessionId === currentSessionId) { - try { - const r = latestMessage.data || {}; - const textResult = typeof r.result === 'string' ? r.result : ''; - // Flush buffered deltas before finalizing - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; - - setChatMessages(prev => { - const updated = [...prev]; - // Try to consolidate into the last streaming assistant message - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - // Replace streaming content with the final content so deltas don't remain - const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || ''); - last.content = finalContent; - last.isStreaming = false; - } else if (textResult && textResult.trim()) { - updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); - } - return updated; - }); - } catch (e) { - console.warn('Error handling cursor-result message:', e); - } - } - - // Store session ID for future use and trigger refresh (for new sessions) - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); - if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { - setCurrentSessionId(cursorCompletedSessionId); - sessionStorage.removeItem('pendingSessionId'); - - // Trigger a project refresh to update the sidebar with the new session - if (window.refreshProjects) { - setTimeout(() => window.refreshProjects(), 500); - } - } - break; - - case 'cursor-output': - // Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads - try { - const raw = String(latestMessage.data ?? ''); - const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim(); - if (cleaned) { - streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned); - if (!streamTimerRef.current) { - streamTimerRef.current = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = last.content ? `${last.content}\n${chunk}` : chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - } - } catch (e) { - console.warn('Error handling cursor-output message:', e); - } - break; - - case 'claude-complete': - // Get session ID from message or fall back to current session - const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - // Update UI state if this is the current session OR if we don't have a session ID yet (new session) - if (completedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the completed session as inactive and not processing - if (completedSessionId) { - if (onSessionInactive) { - onSessionInactive(completedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(completedSessionId); - } - } - - // If we have a pending session ID and the conversation completed successfully, use it - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { - setCurrentSessionId(pendingSessionId); - sessionStorage.removeItem('pendingSessionId'); - - // No need to manually refresh - projects_updated WebSocket message will handle it - console.log('✅ New session complete, ID set to:', pendingSessionId); - } - - // Clear persisted chat messages after successful completion - if (selectedProject && latestMessage.exitCode === 0) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } - // Conversation finished; clear any stale permission prompts. - // This does not remove saved permissions; it only resets transient UI state. - setPendingPermissionRequests([]); - break; - - case 'codex-response': - // Handle Codex SDK responses - const codexData = latestMessage.data; - if (codexData) { - // Handle item events - if (codexData.type === 'item') { - switch (codexData.itemType) { - case 'agent_message': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - break; - - case 'reasoning': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date(), - isThinking: true - }]); - } - break; - - case 'command_execution': - if (codexData.command) { - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'Bash', - toolInput: codexData.command, - toolResult: codexData.output || null, - exitCode: codexData.exitCode - }]); - } - break; - - case 'file_change': - if (codexData.changes?.length > 0) { - const changesList = codexData.changes.map(c => `${c.kind}: ${c.path}`).join('\n'); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'FileChanges', - toolInput: changesList, - toolResult: `Status: ${codexData.status}` - }]); - } - break; - - case 'mcp_tool_call': - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: `${codexData.server}:${codexData.tool}`, - toolInput: JSON.stringify(codexData.arguments, null, 2), - toolResult: codexData.result ? JSON.stringify(codexData.result, null, 2) : (codexData.error?.message || null) - }]); - break; - - case 'error': - if (codexData.message?.content) { - setChatMessages(prev => [...prev, { - type: 'error', - content: codexData.message.content, - timestamp: new Date() - }]); - } - break; - - default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); - } - } - - // Handle turn complete - if (codexData.type === 'turn_complete') { - // Turn completed, message stream done - setIsLoading(false); - } - - // Handle turn failed - if (codexData.type === 'turn_failed') { - setIsLoading(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: codexData.error?.message || 'Turn failed', - timestamp: new Date() - }]); - } - } - break; - - case 'codex-complete': - // Handle Codex session completion - const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - if (codexCompletedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (codexCompletedSessionId) { - if (onSessionInactive) { - onSessionInactive(codexCompletedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(codexCompletedSessionId); - } - } - - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(codexActualSessionId); - } - sessionStorage.removeItem('pendingSessionId'); - console.log('Codex session complete, ID set to:', codexPendingSessionId); - } - - if (selectedProject) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } - break; - - case 'codex-error': - // Handle Codex errors - setIsLoading(false); - setCanAbortSession(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: latestMessage.error || 'An error occurred with Codex', - timestamp: new Date() - }]); - break; - - case 'session-aborted': { - // Get session ID from message or fall back to current session - const abortedSessionId = latestMessage.sessionId || currentSessionId; - - // Only update UI state if this is the current session - if (abortedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the aborted session as inactive and not processing - if (abortedSessionId) { - if (onSessionInactive) { - onSessionInactive(abortedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(abortedSessionId); - } - } - - // Abort ends the run; clear permission prompts to avoid dangling UI state. - // This does not change allowlists; it only clears the current banner. - setPendingPermissionRequests([]); - - setChatMessages(prev => [...prev, { - type: 'assistant', - content: 'Session interrupted by user.', - timestamp: new Date() - }]); - break; - } - - case 'session-status': { - const statusSessionId = latestMessage.sessionId; - const isCurrentSession = statusSessionId === currentSessionId || - (selectedSession && statusSessionId === selectedSession.id); - if (isCurrentSession && latestMessage.isProcessing) { - // Session is currently processing, restore UI state - setIsLoading(true); - setCanAbortSession(true); - if (onSessionProcessing) { - onSessionProcessing(statusSessionId); - } - } - break; - } - - case 'claude-status': - // Handle Claude working status messages - const statusData = latestMessage.data; - if (statusData) { - // Parse the status message to extract relevant information - let statusInfo = { - text: 'Working...', - tokens: 0, - can_interrupt: true - }; - - // Check for different status message formats - if (statusData.message) { - statusInfo.text = statusData.message; - } else if (statusData.status) { - statusInfo.text = statusData.status; - } else if (typeof statusData === 'string') { - statusInfo.text = statusData; - } - - // Extract token count - if (statusData.tokens) { - statusInfo.tokens = statusData.tokens; - } else if (statusData.token_count) { - statusInfo.tokens = statusData.token_count; - } - - // Check if can interrupt - if (statusData.can_interrupt !== undefined) { - statusInfo.can_interrupt = statusData.can_interrupt; - } - - setClaudeStatus(statusInfo); - setIsLoading(true); - setCanAbortSession(statusInfo.can_interrupt); - } - break; - - } - } - }, [latestMessage]); - - // Load file list when project changes - useEffect(() => { - if (selectedProject) { - fetchProjectFiles(); - } - }, [selectedProject]); - - const fetchProjectFiles = async () => { - try { - const response = await api.getFiles(selectedProject.name); - if (response.ok) { - const files = await response.json(); - // Flatten the file tree to get all file paths - const flatFiles = flattenFileTree(files); - setFileList(flatFiles); - } - } catch (error) { - console.error('Error fetching files:', error); - } - }; - - const flattenFileTree = (files, basePath = '') => { - let result = []; - for (const file of files) { - const fullPath = basePath ? `${basePath}/${file.name}` : file.name; - if (file.type === 'directory' && file.children) { - result = result.concat(flattenFileTree(file.children, fullPath)); - } else if (file.type === 'file') { - result.push({ - name: file.name, - path: fullPath, - relativePath: file.path - }); - } - } - return result; - }; - - - // Handle @ symbol detection and file filtering - useEffect(() => { - const textBeforeCursor = input.slice(0, cursorPosition); - const lastAtIndex = textBeforeCursor.lastIndexOf('@'); - - if (lastAtIndex !== -1) { - const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); - // Check if there's a space after the @ symbol (which would end the file reference) - if (!textAfterAt.includes(' ')) { - setAtSymbolPosition(lastAtIndex); - setShowFileDropdown(true); - - // Filter files based on the text after @ - const filtered = fileList.filter(file => - file.name.toLowerCase().includes(textAfterAt.toLowerCase()) || - file.path.toLowerCase().includes(textAfterAt.toLowerCase()) - ).slice(0, 10); // Limit to 10 results - - setFilteredFiles(filtered); - setSelectedFileIndex(-1); - } else { - setShowFileDropdown(false); - setAtSymbolPosition(-1); - } - } else { - setShowFileDropdown(false); - setAtSymbolPosition(-1); - } - }, [input, cursorPosition, fileList]); - - const activeFileMentions = useMemo(() => { - if (!input || fileMentions.length === 0) return []; - return fileMentions.filter(path => input.includes(path)); - }, [fileMentions, input]); - - const sortedFileMentions = useMemo(() => { - if (activeFileMentions.length === 0) return []; - const unique = Array.from(new Set(activeFileMentions)); - return unique.sort((a, b) => b.length - a.length); - }, [activeFileMentions]); - - const fileMentionRegex = useMemo(() => { - if (sortedFileMentions.length === 0) return null; - const pattern = sortedFileMentions.map(escapeRegExp).join('|'); - return new RegExp(`(${pattern})`, 'g'); - }, [sortedFileMentions]); - - const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]); - - const renderInputWithMentions = useCallback((text) => { - if (!text) return ''; - if (!fileMentionRegex) return text; - const parts = text.split(fileMentionRegex); - return parts.map((part, index) => ( - fileMentionSet.has(part) ? ( - - {part} - - ) : ( - {part} - ) - )); - }, [fileMentionRegex, fileMentionSet]); - - // Debounced input handling - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedInput(input); - }, 150); // 150ms debounce - - return () => clearTimeout(timer); - }, [input]); - - // Show only recent messages for better performance - const visibleMessages = useMemo(() => { - if (chatMessages.length <= visibleMessageCount) { - return chatMessages; - } - return chatMessages.slice(-visibleMessageCount); - }, [chatMessages, visibleMessageCount]); - - // Capture scroll position before render when auto-scroll is disabled - useEffect(() => { - if (!autoScrollToBottom && scrollContainerRef.current) { - const container = scrollContainerRef.current; - scrollPositionRef.current = { - height: container.scrollHeight, - top: container.scrollTop - }; - } - }); - - useEffect(() => { - // Auto-scroll to bottom when new messages arrive - if (scrollContainerRef.current && chatMessages.length > 0) { - if (autoScrollToBottom) { - // If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up - if (!isUserScrolledUp) { - setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated - } - } else { - // When auto-scroll is disabled, preserve the visual position - const container = scrollContainerRef.current; - const prevHeight = scrollPositionRef.current.height; - const prevTop = scrollPositionRef.current.top; - const newHeight = container.scrollHeight; - const heightDiff = newHeight - prevHeight; - - // If content was added above the current view, adjust scroll position - if (heightDiff > 0 && prevTop > 0) { - container.scrollTop = prevTop + heightDiff; - } - } - } - }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); - - // Scroll to bottom when messages first load after session switch - useEffect(() => { - if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) { - // Only scroll if we're not in the middle of loading a session - // This prevents the "double scroll" effect during session switching - // Reset scroll state when switching sessions - setIsUserScrolledUp(false); - setTimeout(() => { - scrollToBottom(); - // After scrolling, the scroll event handler will naturally set isUserScrolledUp based on position - }, 200); // Delay to ensure full rendering - } - }, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes - - // Add scroll event listener to detect user scrolling - useEffect(() => { - const scrollContainer = scrollContainerRef.current; - if (scrollContainer) { - scrollContainer.addEventListener('scroll', handleScroll); - return () => scrollContainer.removeEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - // Initial textarea setup - set to 2 rows height - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; - - // Check if initially expanded - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; - setIsTextareaExpanded(isExpanded); - } - }, []); // Only run once on mount - - // Reset textarea height when input is cleared programmatically - useEffect(() => { - if (textareaRef.current && !input.trim()) { - textareaRef.current.style.height = 'auto'; - setIsTextareaExpanded(false); - } - }, [input]); - - // Load token usage when session changes for Claude sessions only - // (Codex token usage is included in messages response, Cursor doesn't support it) - useEffect(() => { - if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { - setTokenBudget(null); - return; - } - - const sessionProvider = selectedSession.__provider || 'claude'; - - // Skip for Codex (included in messages) and Cursor (not supported) - if (sessionProvider !== 'claude') { - return; - } - - // Fetch token usage for Claude sessions - const fetchInitialTokenUsage = async () => { - try { - const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; - const response = await authenticatedFetch(url); - if (response.ok) { - const data = await response.json(); - setTokenBudget(data); - } else { - setTokenBudget(null); - } - } catch (error) { - console.error('Failed to fetch initial token usage:', error); - } - }; - - fetchInitialTokenUsage(); - }, [selectedSession?.id, selectedSession?.__provider, selectedProject?.path]); - - const handleTranscript = useCallback((text) => { - if (text.trim()) { - setInput(prevInput => { - const newInput = prevInput.trim() ? `${prevInput} ${text}` : text; - - // Update textarea height after setting new content - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; - - // Check if expanded after transcript - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; - setIsTextareaExpanded(isExpanded); - } - }, 0); - - return newInput; - }); - } - }, []); - - // Load earlier messages by increasing the visible message count - const loadEarlierMessages = useCallback(() => { - setVisibleMessageCount(prevCount => prevCount + 100); - }, []); - - // Handle image files from drag & drop or file picker - const handleImageFiles = useCallback((files) => { - const validFiles = files.filter(file => { - try { - // Validate file object and properties - if (!file || typeof file !== 'object') { - console.warn('Invalid file object:', file); - return false; - } - - if (!file.type || !file.type.startsWith('image/')) { - return false; - } - - if (!file.size || file.size > 5 * 1024 * 1024) { - // Safely get file name with fallback - const fileName = file.name || 'Unknown file'; - setImageErrors(prev => { - const newMap = new Map(prev); - newMap.set(fileName, 'File too large (max 5MB)'); - return newMap; - }); - return false; - } - - return true; - } catch (error) { - console.error('Error validating file:', error, file); - return false; - } - }); - - if (validFiles.length > 0) { - setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images - } - }, []); - - // Handle clipboard paste for images - const handlePaste = useCallback(async (e) => { - const items = Array.from(e.clipboardData.items); - - for (const item of items) { - if (item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - handleImageFiles([file]); - } - } - } - - // Fallback for some browsers/platforms - if (items.length === 0 && e.clipboardData.files.length > 0) { - const files = Array.from(e.clipboardData.files); - const imageFiles = files.filter(f => f.type.startsWith('image/')); - if (imageFiles.length > 0) { - handleImageFiles(imageFiles); - } - } - }, [handleImageFiles]); - - // Setup dropzone - const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ - accept: { - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] - }, - maxSize: 5 * 1024 * 1024, // 5MB - maxFiles: 5, - onDrop: handleImageFiles, - noClick: true, // We'll use our own button - noKeyboard: true - }); - - const handleSubmit = useCallback(async (e) => { - e.preventDefault(); - if (!input.trim() || isLoading || !selectedProject) return; - - // Apply thinking mode prefix if selected - let messageContent = input; - const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode); - if (selectedThinkingMode && selectedThinkingMode.prefix) { - messageContent = `${selectedThinkingMode.prefix}: ${input}`; - } - - // Upload images first if any - let uploadedImages = []; - if (attachedImages.length > 0) { - const formData = new FormData(); - attachedImages.forEach(file => { - formData.append('images', file); - }); - - try { - const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, { - method: 'POST', - headers: {}, // Let browser set Content-Type for FormData - body: formData - }); - - if (!response.ok) { - throw new Error('Failed to upload images'); - } - - const result = await response.json(); - uploadedImages = result.images; - } catch (error) { - console.error('Image upload failed:', error); - setChatMessages(prev => [...prev, { - type: 'error', - content: `Failed to upload images: ${error.message}`, - timestamp: new Date() - }]); - return; - } - } - - const userMessage = { - type: 'user', - content: input, - images: uploadedImages, - timestamp: new Date() - }; - - setChatMessages(prev => [...prev, userMessage]); - setIsLoading(true); - setCanAbortSession(true); - // Set a default status when starting - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true - }); - - // Always scroll to bottom when user sends a message and reset scroll state - setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response - setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered - - // Determine effective session id for replies to avoid race on state updates - const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); - - // Session Protection: Mark session as active to prevent automatic project updates during conversation - // Use existing session if available; otherwise a temporary placeholder until backend provides real ID - const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; - if (!effectiveSessionId && !selectedSession?.id) { - // We are starting a brand-new session in this view. Track it so we only - // accept streaming updates for this run. - pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; - } - if (onSessionActive) { - onSessionActive(sessionToActivate); - } - - // Get tools settings from localStorage based on provider - const getToolsSettings = () => { - try { - const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-settings' : 'claude-settings'; - const savedSettings = safeLocalStorage.getItem(settingsKey); - if (savedSettings) { - return JSON.parse(savedSettings); - } - } catch (error) { - console.error('Error loading tools settings:', error); - } - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false - }; - }; - - const toolsSettings = getToolsSettings(); - - // Send command based on provider - if (provider === 'cursor') { - // Send Cursor command (always use cursor-command; include resume/sessionId when replying) - sendMessage({ - type: 'cursor-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - // Prefer fullPath (actual cwd for project), fallback to path - cwd: selectedProject.fullPath || selectedProject.path, - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: effectiveSessionId, - resume: !!effectiveSessionId, - model: cursorModel, - skipPermissions: toolsSettings?.skipPermissions || false, - toolsSettings: toolsSettings - } - }); - } else if (provider === 'codex') { - // Send Codex command - sendMessage({ - type: 'codex-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: selectedProject.fullPath || selectedProject.path, - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: effectiveSessionId, - resume: !!effectiveSessionId, - model: codexModel, - permissionMode: permissionMode === 'plan' ? 'default' : permissionMode - } - }); - } else { - // Send Claude command (existing code) - sendMessage({ - type: 'claude-command', - command: messageContent, - options: { - projectPath: selectedProject.path, - cwd: selectedProject.fullPath, - sessionId: currentSessionId, - resume: !!currentSessionId, - toolsSettings: toolsSettings, - permissionMode: permissionMode, - model: claudeModel, - images: uploadedImages // Pass images to backend - } - }); - } - - setInput(''); - setAttachedImages([]); - setUploadingImages(new Map()); - setImageErrors(new Map()); - setIsTextareaExpanded(false); - setThinkingMode('none'); // Reset thinking mode after sending - - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - } - - // Clear the saved draft since message was sent - if (selectedProject) { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); - } - }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]); - - const handleGrantToolPermission = useCallback((suggestion) => { - if (!suggestion || provider !== 'claude') { - return { success: false }; - } - return grantClaudeToolPermission(suggestion.entry); - }, [provider]); - - // Send a UI decision back to the server (single or batched request IDs). - // This does not validate tool inputs or permissions; the backend enforces rules. - // It exists so "Allow & remember" can resolve multiple queued prompts at once. - const handlePermissionDecision = useCallback((requestIds, decision) => { - const ids = Array.isArray(requestIds) ? requestIds : [requestIds]; - const validIds = ids.filter(Boolean); - if (validIds.length === 0) { - return; - } - - validIds.forEach((requestId) => { - sendMessage({ - type: 'claude-permission-response', - requestId, - allow: Boolean(decision?.allow), - updatedInput: decision?.updatedInput, - message: decision?.message, - rememberEntry: decision?.rememberEntry - }); - }); - - setPendingPermissionRequests(prev => { - const next = prev.filter(req => !validIds.includes(req.requestId)); - if (next.length === 0) { - setClaudeStatus(null); - } - return next; - }); - }, [sendMessage]); - - // Store handleSubmit in ref so handleCustomCommand can access it - useEffect(() => { - handleSubmitRef.current = handleSubmit; - }, [handleSubmit]); - - const selectCommand = (command) => { - if (!command) return; - - // Prepare the input with command name and any arguments that were already typed - const textBeforeSlash = input.slice(0, slashPosition); - const textAfterSlash = input.slice(slashPosition); - const spaceIndex = textAfterSlash.indexOf(' '); - const textAfterQuery = spaceIndex !==-1 ? textAfterSlash.slice(spaceIndex) : ''; - - const newInput = textBeforeSlash + command.name + ' ' + textAfterQuery; - - // Update input temporarily so executeCommand can parse arguments - setInput(newInput); - - // Hide command menu - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - - // Clear debounce timer - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - - // Execute the command (which will load its content and send to Claude) - executeCommand(command); - }; - - const handleKeyDown = (e) => { - // Handle command menu navigation - if (showCommandMenu && filteredCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedCommandIndex(prev => - prev < filteredCommands.length - 1 ? prev + 1 : 0 - ); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedCommandIndex(prev => - prev > 0 ? prev - 1 : filteredCommands.length - 1 - ); - return; - } - if (e.key === 'Tab' || e.key === 'Enter') { - e.preventDefault(); - if (selectedCommandIndex >= 0) { - selectCommand(filteredCommands[selectedCommandIndex]); - } else if (filteredCommands.length > 0) { - selectCommand(filteredCommands[0]); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - return; - } - } - - // Handle file dropdown navigation - if (showFileDropdown && filteredFiles.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedFileIndex(prev => - prev < filteredFiles.length - 1 ? prev + 1 : 0 - ); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedFileIndex(prev => - prev > 0 ? prev - 1 : filteredFiles.length - 1 - ); - return; - } - if (e.key === 'Tab' || e.key === 'Enter') { - e.preventDefault(); - if (selectedFileIndex >= 0) { - selectFile(filteredFiles[selectedFileIndex]); - } else if (filteredFiles.length > 0) { - selectFile(filteredFiles[0]); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setShowFileDropdown(false); - return; - } - } - - // Handle Tab key for mode switching (only when dropdowns are not showing) - if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) { - e.preventDefault(); - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - const currentIndex = modes.indexOf(permissionMode); - const nextIndex = (currentIndex + 1) % modes.length; - const newMode = modes[nextIndex]; - setPermissionMode(newMode); - - // Save mode for this session - if (selectedSession?.id) { - localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); - } - return; - } - - // Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line - if (e.key === 'Enter') { - // If we're in composition, don't send message - if (e.nativeEvent.isComposing) { - return; // Let IME handle the Enter key - } - - if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { - // Ctrl+Enter or Cmd+Enter: Send message - e.preventDefault(); - handleSubmit(e); - } else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { - // Plain Enter: Send message only if not in IME composition - if (!sendByCtrlEnter) { - e.preventDefault(); - handleSubmit(e); - } - } - // Shift+Enter: Allow default behavior (new line) - } - }; - - const selectFile = (file) => { - const textBeforeAt = input.slice(0, atSymbolPosition); - const textAfterAtQuery = input.slice(atSymbolPosition); - const spaceIndex = textAfterAtQuery.indexOf(' '); - const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : ''; - - const newInput = textBeforeAt + file.path + ' ' + textAfterQuery; - const newCursorPos = textBeforeAt.length + file.path.length + 1; - - // Immediately ensure focus is maintained - if (textareaRef.current && !textareaRef.current.matches(':focus')) { - textareaRef.current.focus(); - } - - // Update input and cursor position - setInput(newInput); - setCursorPosition(newCursorPos); - setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path])); - - // Hide dropdown - setShowFileDropdown(false); - setAtSymbolPosition(-1); - - // Set cursor position synchronously - if (textareaRef.current) { - // Use requestAnimationFrame for smoother updates - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); - // Ensure focus is maintained - if (!textareaRef.current.matches(':focus')) { - textareaRef.current.focus(); - } - } - }); - } - }; - - const handleInputChange = (e) => { - const newValue = e.target.value; - const cursorPos = e.target.selectionStart; - - // Auto-select Claude provider if no session exists and user starts typing - if (!currentSessionId && newValue.trim() && provider === 'claude') { - // Provider is already set to 'claude' by default, so no need to change it - // The session will be created automatically when they submit - } - - setInput(newValue); - setCursorPosition(cursorPos); - - // Handle height reset when input becomes empty - if (!newValue.trim()) { - e.target.style.height = 'auto'; - setIsTextareaExpanded(false); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - return; - } - - // Detect slash command at cursor position - // Look backwards from cursor to find a slash that starts a command - const textBeforeCursor = newValue.slice(0, cursorPos); - - // Check if we're in a code block (simple heuristic: between triple backticks) - const backticksBefore = (textBeforeCursor.match(/```/g) || []).length; - const inCodeBlock = backticksBefore % 2 === 1; - - if (inCodeBlock) { - // Don't show command menu in code blocks - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - return; - } - - // Find the last slash before cursor that could start a command - // Slash is valid if it's at the start or preceded by whitespace - const slashPattern = /(^|\s)\/(\S*)$/; - const match = textBeforeCursor.match(slashPattern); - - if (match) { - const slashPos = match.index + match[1].length; // Position of the slash - const query = match[2]; // Text after the slash - - // Update states with debouncing for query - setSlashPosition(slashPos); - setShowCommandMenu(true); - setSelectedCommandIndex(-1); - - // Debounce the command query update - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - - commandQueryTimerRef.current = setTimeout(() => { - setCommandQuery(query); - }, 150); // 150ms debounce - } else { - // No slash command detected - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - } - }; - - const syncInputOverlayScroll = useCallback((target) => { - if (!inputHighlightRef.current || !target) return; - inputHighlightRef.current.scrollTop = target.scrollTop; - inputHighlightRef.current.scrollLeft = target.scrollLeft; - }, []); - - const handleTextareaClick = (e) => { - setCursorPosition(e.target.selectionStart); - }; - - -// ! Unused - const handleNewSession = () => { - setChatMessages([]); - setInput(''); - setIsLoading(false); - setCanAbortSession(false); - }; - - const handleAbortSession = () => { - if (currentSessionId && canAbortSession) { - sendMessage({ - type: 'abort-session', - sessionId: currentSessionId, - provider: provider - }); - } - }; - - const handleModeSwitch = () => { - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - const currentIndex = modes.indexOf(permissionMode); - const nextIndex = (currentIndex + 1) % modes.length; - const newMode = modes[nextIndex]; - setPermissionMode(newMode); - - // Save mode for this session - if (selectedSession?.id) { - localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); - } - }; - - // Don't render if no project is selected - if (!selectedProject) { - return ( -
-
-

Select a project to start chatting with Claude

-
-
- ); - } - - return ( - <> - -
- {/* Messages Area - Scrollable Middle Section */} -
- {isLoadingSessionMessages && chatMessages.length === 0 ? ( -
-
-
-

{t('session.loading.sessionMessages')}

-
-
- ) : chatMessages.length === 0 ? ( -
- {!selectedSession && !currentSessionId && ( -
-

{t('providerSelection.title')}

-

- {t('providerSelection.description')} -

- -
- {/* Claude Button */} - - - {/* Cursor Button */} - - - {/* Codex Button */} - -
- - {/* Model Selection - Always reserve space to prevent jumping */} -
- - {provider === 'claude' ? ( - - ) : provider === 'codex' ? ( - - ) : ( - - )} -
- -

- {provider === 'claude' - ? t('providerSelection.readyPrompt.claude', { model: claudeModel }) - : provider === 'cursor' - ? t('providerSelection.readyPrompt.cursor', { model: cursorModel }) - : provider === 'codex' - ? t('providerSelection.readyPrompt.codex', { model: codexModel }) - : t('providerSelection.readyPrompt.default') - } -

- - {/* Show NextTaskBanner when provider is selected and ready, only if TaskMaster is installed */} - {provider && tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} - {selectedSession && ( -
-

{t('session.continue.title')}

-

- {t('session.continue.description')} -

- - {/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */} - {tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} -
- ) : ( - <> - {/* Loading indicator for older messages */} - {isLoadingMoreMessages && ( -
-
-
-

{t('session.loading.olderMessages')}

-
-
- )} - - {/* Indicator showing there are more messages to load */} - {hasMoreMessages && !isLoadingMoreMessages && ( -
- {totalMessages > 0 && ( - - {t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })} • - {t('session.messages.scrollToLoad')} - - )} -
- )} - - {/* Legacy message count indicator (for non-paginated view) */} - {!hasMoreMessages && chatMessages.length > visibleMessageCount && ( -
- {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} • - -
- )} - - {visibleMessages.map((message, index) => { - const prevMessage = index > 0 ? visibleMessages[index - 1] : null; - - return ( - - ); - })} - - )} - - {isLoading && ( -
-
-
-
- {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( - - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( - - ) : ( - - )} -
-
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude'}
- {/* Abort button removed - functionality not yet implemented at backend */} -
-
-
-
-
-
- Thinking... -
-
-
-
- )} - -
-
- - - {/* Input Area - Fixed Bottom */} -
- -
- -
- {/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} -
- {pendingPermissionRequests.length > 0 && ( - // Permission banner for tool approvals. This renders the input, allows - // "allow once" or "allow & remember", and supports batching similar requests. - // It does not persist permissions by itself; persistence is handled by - // the existing localStorage-based settings helpers, introduced to surface - // approvals before tool execution resumes. -
- {pendingPermissionRequests.map((request) => { - const rawInput = formatToolInputForDisplay(request.input); - const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput); - const settings = getClaudeSettings(); - const alreadyAllowed = permissionEntry - ? settings.allowedTools.includes(permissionEntry) - : false; - const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember'; - // Group pending prompts that resolve to the same allow rule so - // a single "Allow & remember" can clear them in one click. - // This does not attempt fuzzy matching; it only batches identical rules. - const matchingRequestIds = permissionEntry - ? pendingPermissionRequests - .filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry) - .map(item => item.requestId) - : [request.requestId]; - - return ( -
-
-
-
- Permission required -
-
- Tool: {request.toolName} -
-
- {permissionEntry && ( -
- Allow rule: {permissionEntry} -
- )} -
- - {rawInput && ( -
- - View tool input - -
-                          {rawInput}
-                        
-
- )} - -
- - - -
-
- ); - })} -
- )} - -
- - - {/* Thinking Mode Selector */} - { - provider === 'claude' && ( - - - )} - {/* Token usage pie chart - positioned next to mode indicator */} - - - {/* Slash commands button */} - - - {/* Clear input button - positioned to the right of token pie, only shows when there's input */} - {input.trim() && ( - - )} - - {/* Scroll to bottom button - positioned next to mode indicator */} - {isUserScrolledUp && chatMessages.length > 0 && ( - - )} -
-
- -
- {/* Drag overlay */} - {isDragActive && ( -
-
- - - -

Drop images here

-
-
- )} - - {/* Image attachments preview */} - {attachedImages.length > 0 && ( -
-
- {attachedImages.map((file, index) => ( - { - setAttachedImages(prev => prev.filter((_, i) => i !== index)); - }} - uploadProgress={uploadingImages.get(file.name)} - error={imageErrors.get(file.name)} - /> - ))} -
-
- )} - - {/* File dropdown - positioned outside dropzone to avoid conflicts */} - {showFileDropdown && filteredFiles.length > 0 && ( -
- {filteredFiles.map((file, index) => ( -
{ - // Prevent textarea from losing focus on mobile - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectFile(file); - }} - > -
{file.name}
-
- {file.path} -
-
- ))} -
- )} - - {/* Command Menu */} - { - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - }} - position={{ - top: textareaRef.current - ? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316) - : 0, - left: textareaRef.current - ? textareaRef.current.getBoundingClientRect().left - : 16, - bottom: textareaRef.current - ? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8 - : 90 - }} - isOpen={showCommandMenu} - frequentCommands={commandQuery ? [] : frequentCommands} - /> - -
- - -
-