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 */}
-
setShowVersionModal(false)}
- aria-label={t('versionUpdate.ariaLabels.closeModal')}
- />
-
- {/* Modal */}
-
- {/* Header */}
-
-
-
-
-
{t('versionUpdate.title')}
-
- {releaseInfo?.title || t('versionUpdate.newVersionReady')}
-
-
-
-
setShowVersionModal(false)}
- className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
- >
-
-
-
-
-
-
- {/* Version Info */}
-
-
- {t('versionUpdate.currentVersion')}
- {currentVersion}
-
-
- {t('versionUpdate.latestVersion')}
- {latestVersion}
-
-
-
- {/* Changelog */}
- {releaseInfo?.body && (
-
-
-
-
- {cleanChangelog(releaseInfo.body)}
-
-
-
- )}
-
- {/* Update Output */}
- {updateOutput && (
-
-
{t('versionUpdate.updateProgress')}
-
-
- )}
-
- {/* Upgrade Instructions */}
- {!isUpdating && !updateOutput && (
-
-
{t('versionUpdate.manualUpgrade')}
-
-
- git checkout main && git pull && npm install
-
-
-
- {t('versionUpdate.manualUpgradeHint')}
-
-
- )}
-
- {/* Actions */}
-
-
setShowVersionModal(false)}
- className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
- >
- {updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
-
- {!updateOutput && (
- <>
-
{
- navigator.clipboard.writeText('git checkout main && git pull && npm install');
- }}
- className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
- >
- {t('versionUpdate.buttons.copyCommand')}
-
-
- {isUpdating ? (
- <>
-
- {t('versionUpdate.buttons.updating')}
- >
- ) : (
- t('versionUpdate.buttons.updateNow')
- )}
-
- >
- )}
-
-
-
- );
- };
-
- 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 */}
-
setSidebarVisible(true)}
- className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
- aria-label={t('versionUpdate.ariaLabels.showSidebar')}
- title={t('versionUpdate.ariaLabels.showSidebar')}
- >
-
-
-
-
-
- {/* Settings Icon */}
-
setShowSettings(true)}
- className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
- aria-label={t('versionUpdate.ariaLabels.settings')}
- title={t('versionUpdate.ariaLabels.settings')}
- >
-
-
-
- {/* Update Indicator */}
- {updateAvailable && (
-
setShowVersionModal(true)}
- className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
- aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
- title={t('versionUpdate.ariaLabels.updateAvailable')}
- >
-
-
-
- )}
-
- )}
-
-
- )}
-
- {/* Mobile Sidebar Overlay */}
- {isMobile && (
-
-
{
- e.stopPropagation();
- setSidebarOpen(false);
- }}
- onTouchStart={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSidebarOpen(false);
- }}
- aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
- />
- e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- setShowSettings(true)}
- updateAvailable={updateAvailable}
- latestVersion={latestVersion}
- currentVersion={currentVersion}
- releaseInfo={releaseInfo}
- onShowVersionModal={() => setShowVersionModal(true)}
- isPWA={isPWA}
- isMobile={isMobile}
- onToggleSidebar={() => setSidebarVisible(false)}
- />
-
-
- )}
-
- {/* 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 */}
-
- {copied ? (
-
-
-
-
- {t('codeBlock.copied')}
-
- ) : (
-
-
-
-
-
- {t('codeBlock.copy')}
-
- )}
-
-
- {/* 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 }) => (
-
- ),
- 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) => (
-
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 && (
-
{
- e.stopPropagation();
- onShowSettings();
- }}
- className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm"
- title={t('tools.settings')}
- >
-
-
-
-
-
- )}
-
- {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
-
- {
- e.preventDefault();
- e.stopPropagation();
- if (!onFileOpen) return;
-
- try {
- // Fetch the current file (after the edit)
- const response = await api.readFile(selectedProject?.name, input.file_path);
- const data = await response.json();
-
- if (!response.ok || data.error) {
- console.error('Failed to fetch file:', data.error);
- onFileOpen(input.file_path);
- return;
- }
-
- const currentContent = data.content || '';
-
- // Reverse apply the edit: replace new_string back to old_string to get the file BEFORE the edit
- const oldContent = currentContent.replace(input.new_string, input.old_string);
-
- // Pass the full file before and after the edit
- onFileOpen(input.file_path, {
- old_string: oldContent,
- new_string: currentContent
- });
- } catch (error) {
- console.error('Error preparing diff:', error);
- onFileOpen(input.file_path);
- }
- }}
- className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
- >
- {input.file_path.split('/').pop()}
-
-
-
-
-
- {
- if (!onFileOpen) return;
-
- try {
- // Fetch the current file (after the edit)
- const response = await api.readFile(selectedProject?.name, input.file_path);
- const data = await response.json();
-
- if (!response.ok || data.error) {
- console.error('Failed to fetch file:', data.error);
- onFileOpen(input.file_path);
- return;
- }
-
- const currentContent = data.content || '';
- // Reverse apply the edit: replace new_string back to old_string
- const oldContent = currentContent.replace(input.new_string, input.old_string);
-
- // Pass the full file before and after the edit
- onFileOpen(input.file_path, {
- old_string: oldContent,
- new_string: currentContent
- });
- } catch (error) {
- console.error('Error preparing diff:', error);
- onFileOpen(input.file_path);
- }
- }}
- className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
- >
- {input.file_path}
-
-
- 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:
-
- {
- e.preventDefault();
- e.stopPropagation();
- if (!onFileOpen) return;
-
- try {
- // Fetch the written file from disk
- const response = await api.readFile(selectedProject?.name, input.file_path);
- const data = await response.json();
-
- const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
-
- // New file: old_string is empty, new_string is the full file
- onFileOpen(input.file_path, {
- old_string: '',
- new_string: newContent
- });
- } catch (error) {
- console.error('Error preparing diff:', error);
- // Fallback to tool input content
- onFileOpen(input.file_path, {
- old_string: '',
- new_string: input.content || ''
- });
- }
- }}
- className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
- >
- {input.file_path.split('/').pop()}
-
-
-
-
-
- {
- if (!onFileOpen) return;
-
- try {
- // Fetch the written file from disk
- const response = await api.readFile(selectedProject?.name, input.file_path);
- const data = await response.json();
-
- const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
-
- // New file: old_string is empty, new_string is the full file
- onFileOpen(input.file_path, {
- old_string: '',
- new_string: newContent
- });
- } catch (error) {
- console.error('Error preparing diff:', error);
- // Fallback to tool input content
- onFileOpen(input.file_path, {
- old_string: '',
- new_string: input.content || ''
- });
- }
- }}
- className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
- >
- {input.file_path}
-
-
- 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{' '}
- onFileOpen && onFileOpen(input.file_path)}
- className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
- >
- {filename}
-
-
- );
- }
- } 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 (
-
- );
- })()}
-
- );
- })()
- ) : 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) => (
-
-
-
- {option.number}
-
-
- {option.text}
-
- {option.isSelected && (
- ❯
- )}
-
-
- ))}
-
-
-
-
- ⏳ 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
-
onFileOpen && onFileOpen(input.file_path)}
- className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono transition-colors"
- >
- {filename}
-
-
-
- );
- }
- } catch (e) {
- return (
-
- );
- }
- })()
- ) : 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
-
- ) : 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 (
-
- );
- } 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 (
-
-
- {uploadProgress !== undefined && uploadProgress < 100 && (
-
- )}
- {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 */}
-
{
- setProvider('claude');
- localStorage.setItem('selected-provider', 'claude');
- // Focus input after selection
- setTimeout(() => textareaRef.current?.focus(), 100);
- }}
- className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
- provider === 'claude'
- ? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
- : 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
- }`}
- >
-
-
-
-
Claude Code
-
{t('providerSelection.providerInfo.anthropic')}
-
-
- {provider === 'claude' && (
-
- )}
-
-
- {/* Cursor Button */}
-
{
- setProvider('cursor');
- localStorage.setItem('selected-provider', 'cursor');
- // Focus input after selection
- setTimeout(() => textareaRef.current?.focus(), 100);
- }}
- className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
- provider === 'cursor'
- ? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
- : 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
- }`}
- >
-
-
-
-
Cursor
-
{t('providerSelection.providerInfo.cursorEditor')}
-
-
- {provider === 'cursor' && (
-
- )}
-
-
- {/* Codex Button */}
-
{
- setProvider('codex');
- localStorage.setItem('selected-provider', 'codex');
- // Focus input after selection
- setTimeout(() => textareaRef.current?.focus(), 100);
- }}
- className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
- provider === 'codex'
- ? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
- : 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
- }`}
- >
-
-
-
-
Codex
-
{t('providerSelection.providerInfo.openai')}
-
-
- {provider === 'codex' && (
-
- )}
-
-
-
- {/* Model Selection - Always reserve space to prevent jumping */}
-
-
- {t('providerSelection.selectModel')}
-
- {provider === 'claude' ? (
- {
- const newModel = e.target.value;
- setClaudeModel(newModel);
- localStorage.setItem('claude-model', newModel);
- }}
- className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
- >
- {CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
- {label}
- ))}
-
- ) : provider === 'codex' ? (
- {
- const newModel = e.target.value;
- setCodexModel(newModel);
- localStorage.setItem('codex-model', newModel);
- }}
- className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]"
- >
- {CODEX_MODELS.OPTIONS.map(({ value, label }) => (
- {label}
- ))}
-
- ) : (
- {
- const newModel = e.target.value;
- setCursorModel(newModel);
- localStorage.setItem('cursor-model', newModel);
- }}
- className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
- disabled={provider !== 'cursor'}
- >
- {CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
- {label}
- ))}
-
- )}
-
-
-
- {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 })} •
-
- {t('session.messages.loadEarlier')}
-
-
- )}
-
- {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}
-
-
- )}
-
-
- handlePermissionDecision(request.requestId, { allow: true })}
- className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
- >
- Allow once
-
- {
- if (permissionEntry && !alreadyAllowed) {
- handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
- }
- handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
- }}
- className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
- permissionEntry
- ? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
- : 'border-gray-300 text-gray-400 cursor-not-allowed'
- }`}
- disabled={!permissionEntry}
- >
- {rememberLabel}
-
- handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
- className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
- >
- Deny
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
- {permissionMode === 'default' && t('codex.modes.default')}
- {permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
- {permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
- {permissionMode === 'plan' && t('codex.modes.plan')}
-
-
-
-
- {/* Thinking Mode Selector */}
- {
- provider === 'claude' && (
-
-
- )}
- {/* Token usage pie chart - positioned next to mode indicator */}
-
-
- {/* Slash commands button */}
-
{
- const isOpening = !showCommandMenu;
- setShowCommandMenu(isOpening);
- setCommandQuery('');
- setSelectedCommandIndex(-1);
-
- // When opening, ensure all commands are shown
- if (isOpening) {
- setFilteredCommands(slashCommands);
- }
-
- if (textareaRef.current) {
- textareaRef.current.focus();
- }
- }}
- className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
- title={t('input.showAllCommands')}
- >
-
-
-
- {/* Command count badge */}
- {slashCommands.length > 0 && (
-
- {slashCommands.length}
-
- )}
-
-
- {/* Clear input button - positioned to the right of token pie, only shows when there's input */}
- {input.trim() && (
-
{
- e.preventDefault();
- e.stopPropagation();
- setInput('');
- if (textareaRef.current) {
- textareaRef.current.style.height = 'auto';
- textareaRef.current.focus();
- }
- setIsTextareaExpanded(false);
- }}
- className="w-8 h-8 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group shadow-sm"
- title="Clear input"
- >
-
-
-
-
- )}
-
- {/* Scroll to bottom button - positioned next to mode indicator */}
- {isUserScrolledUp && chatMessages.length > 0 && (
-
-
-
-
-
- )}
-
-
-
-
-
-
- >
- );
-}
-
-export default React.memo(ChatInterface);
diff --git a/src/components/CommandMenu.jsx b/src/components/CommandMenu.jsx
index 4420aed..d8f344d 100644
--- a/src/components/CommandMenu.jsx
+++ b/src/components/CommandMenu.jsx
@@ -4,53 +4,61 @@ import React, { useEffect, useRef } from 'react';
* CommandMenu - Autocomplete dropdown for slash commands
*
* @param {Array} commands - Array of command objects to display
- * @param {number} selectedIndex - Currently selected command index
+ * @param {number} selectedIndex - Currently selected command index (index in `commands`)
* @param {Function} onSelect - Callback when a command is selected
* @param {Function} onClose - Callback when menu should close
* @param {Object} position - Position object { top, left } for absolute positioning
* @param {boolean} isOpen - Whether the menu is open
* @param {Array} frequentCommands - Array of frequently used command objects
*/
-const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => {
+const CommandMenu = ({
+ commands = [],
+ selectedIndex = -1,
+ onSelect,
+ onClose,
+ position = { top: 0, left: 0 },
+ isOpen = false,
+ frequentCommands = [],
+}) => {
const menuRef = useRef(null);
const selectedItemRef = useRef(null);
- // Calculate responsive positioning
+ // Calculate responsive menu positioning.
+ // Mobile: dock above chat input. Desktop: clamp to viewport.
const getMenuPosition = () => {
const isMobile = window.innerWidth < 640;
const viewportHeight = window.innerHeight;
- const menuHeight = 300; // Max height of menu
if (isMobile) {
- // On mobile, calculate bottom position dynamically to appear above the input
- // Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
- const inputBottom = position.bottom || 90; // Use provided bottom or default
+ // On mobile, calculate bottom position dynamically to appear above the input.
+ // Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
+ const inputBottom = position.bottom || 90;
return {
position: 'fixed',
- bottom: `${inputBottom}px`, // Position above the input with spacing already included
+ bottom: `${inputBottom}px`, // Position above the input with spacing already included.
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
- maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px
+ maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
};
}
- // On desktop, use provided position but ensure it stays on screen
+ // On desktop, use provided position but ensure it stays on screen.
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
- maxHeight: '300px'
+ maxHeight: '300px',
};
};
const menuPosition = getMenuPosition();
- // Close menu when clicking outside
+ // Close menu when clicking outside.
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
@@ -64,9 +72,11 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
document.removeEventListener('mousedown', handleClickOutside);
};
}
+
+ return undefined;
}, [isOpen, onClose]);
- // Scroll selected item into view
+ // Keep selected keyboard item visible while navigating.
useEffect(() => {
if (selectedItemRef.current && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
@@ -84,7 +94,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
return null;
}
- // Show a message if no commands are available
+ // Show a message if no commands are available.
if (commands.length === 0) {
return (
No commands available
@@ -108,11 +118,20 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
);
}
- // Add frequent commands as a special group if provided
+ // Add frequent commands as a special group if provided.
const hasFrequentCommands = frequentCommands.length > 0;
- // Group commands by namespace
+ const getCommandKey = (command) =>
+ `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
+ const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
+
+ // Group commands by namespace for section rendering.
+ // When frequent commands are shown, avoid duplicate rows in other sections.
const groupedCommands = commands.reduce((groups, command) => {
+ if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
+ return groups;
+ }
+
const namespace = command.namespace || command.type || 'other';
if (!groups[namespace]) {
groups[namespace] = [];
@@ -121,36 +140,33 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
return groups;
}, {});
- // Add frequent commands as a separate group
+ // Add frequent commands as a separate group.
if (hasFrequentCommands) {
- groupedCommands['frequent'] = frequentCommands;
+ groupedCommands.frequent = frequentCommands;
}
- // Order: frequent, builtin, project, user, other
+ // Order: frequent, builtin, project, user, other.
const namespaceOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
- const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]);
+ const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
const namespaceLabels = {
- frequent: '⭐ Frequently Used',
+ frequent: '\u2B50 Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
- other: 'Other Commands'
+ other: 'Other Commands',
};
- // Calculate global index for each command
- let globalIndex = 0;
- const commandsWithIndex = [];
- orderedNamespaces.forEach(namespace => {
- groupedCommands[namespace].forEach(command => {
- commandsWithIndex.push({
- ...command,
- globalIndex: globalIndex++,
- namespace
- });
- });
+ // Keep all selection indices aligned to `commands` (filteredCommands from the hook).
+ // This prevents mismatches between mouse selection (rendered list) and keyboard selection.
+ const commandIndexByKey = new Map();
+ commands.forEach((command, index) => {
+ const key = getCommandKey(command);
+ if (!commandIndexByKey.has(key)) {
+ commandIndexByKey.set(key, index);
+ }
});
return (
@@ -169,7 +185,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
padding: '8px',
opacity: isOpen ? 1 : 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
- transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
+ transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
}}
>
{orderedNamespaces.map((namespace) => (
@@ -182,25 +198,35 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
textTransform: 'uppercase',
color: '#6b7280',
padding: '8px 12px 4px',
- letterSpacing: '0.05em'
+ letterSpacing: '0.05em',
}}
>
{namespaceLabels[namespace] || namespace}
)}
+
{groupedCommands[namespace].map((command) => {
- const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
- const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
+ const commandKey = getCommandKey(command);
+ const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
+ const isSelected = commandIndex === selectedIndex;
return (
onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
- onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)}
+ onMouseEnter={() => {
+ if (onSelect && commandIndex >= 0) {
+ onSelect(command, commandIndex, true);
+ }
+ }}
+ onClick={() => {
+ if (onSelect) {
+ onSelect(command, commandIndex, false);
+ }
+ }}
style={{
display: 'flex',
alignItems: 'flex-start',
@@ -209,9 +235,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
cursor: 'pointer',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
transition: 'background-color 100ms ease-in-out',
- marginBottom: '2px'
+ marginBottom: '2px',
}}
- onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
+ // Prevent textarea blur when clicking a menu item.
+ onMouseDown={(e) => e.preventDefault()}
>
{/* Command icon based on namespace */}
-
- {namespace === 'builtin' && '⚡'}
- {namespace === 'project' && '📁'}
- {namespace === 'user' && '👤'}
- {namespace === 'other' && '📝'}
+
+ {namespace === 'builtin' && '\u26A1'}
+ {namespace === 'project' && '\uD83D\uDCC1'}
+ {namespace === 'user' && '\uD83D\uDC64'}
+ {namespace === 'other' && '\uD83D\uDCDD'}
+ {namespace === 'frequent' && '\u2B50'}
{/* Command name */}
@@ -241,7 +264,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
fontWeight: 600,
fontSize: '14px',
color: '#111827',
- fontFamily: 'monospace'
+ fontFamily: 'monospace',
}}
>
{command.name}
@@ -257,7 +280,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
borderRadius: '4px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
- fontWeight: 500
+ fontWeight: 500,
}}
>
{command.metadata.type}
@@ -274,7 +297,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
marginLeft: '24px',
whiteSpace: 'nowrap',
overflow: 'hidden',
- textOverflow: 'ellipsis'
+ textOverflow: 'ellipsis',
}}
>
{command.description}
@@ -289,10 +312,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
marginLeft: '8px',
color: '#3b82f6',
fontSize: '12px',
- fontWeight: 600
+ fontWeight: 600,
}}
>
- ↵
+ {'\u21B5'}
)}
diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx
index b077b05..6d3df1b 100644
--- a/src/components/GitPanel.jsx
+++ b/src/components/GitPanel.jsx
@@ -53,14 +53,28 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
}, []);
useEffect(() => {
- if (selectedProject) {
- fetchGitStatus();
- fetchBranches();
- fetchRemoteStatus();
- if (activeView === 'history') {
- fetchRecentCommits();
- }
+ // Clear stale repo-scoped state when project changes.
+ setCurrentBranch('');
+ setBranches([]);
+ setGitStatus(null);
+ setRemoteStatus(null);
+ setSelectedFiles(new Set());
+
+ if (!selectedProject) {
+ return;
}
+
+ fetchGitStatus();
+ fetchBranches();
+ fetchRemoteStatus();
+ }, [selectedProject]);
+
+ useEffect(() => {
+ if (!selectedProject || activeView !== 'history') {
+ return;
+ }
+
+ fetchRecentCommits();
}, [selectedProject, activeView]);
// Handle click outside dropdown
@@ -88,6 +102,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus({ error: data.error, details: data.details });
+ setCurrentBranch('');
+ setSelectedFiles(new Set());
} else {
setGitStatus(data);
setCurrentBranch(data.branch || 'main');
@@ -117,6 +133,9 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
}
} catch (error) {
console.error('Error fetching git status:', error);
+ setGitStatus({ error: 'Git operation failed', details: String(error) });
+ setCurrentBranch('');
+ setSelectedFiles(new Set());
} finally {
setIsLoading(false);
}
@@ -129,9 +148,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (!data.error && data.branches) {
setBranches(data.branches);
+ } else {
+ setBranches([]);
}
} catch (error) {
console.error('Error fetching branches:', error);
+ setBranches([]);
}
};
@@ -1400,4 +1422,4 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
);
}
-export default GitPanel;
\ No newline at end of file
+export default GitPanel;
diff --git a/src/components/MainContent.jsx b/src/components/MainContent.jsx
deleted file mode 100644
index c1db1b5..0000000
--- a/src/components/MainContent.jsx
+++ /dev/null
@@ -1,686 +0,0 @@
-/*
- * MainContent.jsx - Main Content Area with Session Protection Props Passthrough
- *
- * SESSION PROTECTION PASSTHROUGH:
- * ===============================
- *
- * This component serves as a passthrough layer for Session Protection functions:
- * - Receives session management functions from App.jsx
- * - Passes them down to ChatInterface.jsx
- *
- * No session protection logic is implemented here - it's purely a props bridge.
- */
-
-import React, { useState, useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import ChatInterface from './ChatInterface';
-import FileTree from './FileTree';
-import CodeEditor from './CodeEditor';
-import StandaloneShell from './StandaloneShell';
-import GitPanel from './GitPanel';
-import ErrorBoundary from './ErrorBoundary';
-import ClaudeLogo from './ClaudeLogo';
-import CursorLogo from './CursorLogo';
-import TaskList from './TaskList';
-import TaskDetail from './TaskDetail';
-import PRDEditor from './PRDEditor';
-import Tooltip from './Tooltip';
-import { useTaskMaster } from '../contexts/TaskMasterContext';
-import { useTasksSettings } from '../contexts/TasksSettingsContext';
-import { api } from '../utils/api';
-
-function MainContent({
- selectedProject,
- selectedSession,
- activeTab,
- setActiveTab,
- ws,
- sendMessage,
- latestMessage,
- isMobile,
- isPWA, // ! Unused
- onMenuClick,
- isLoading,
- onInputFocusChange,
- // Session Protection Props: Functions passed down from App.jsx to manage active session state
- // These functions control when project updates are paused during active conversations
- onSessionActive, // Mark session as active when user sends message
- onSessionInactive, // Mark session as inactive when conversation completes/aborts
- onSessionProcessing, // Mark session as processing (thinking/working)
- onSessionNotProcessing, // Mark session as not processing (finished thinking)
- processingSessions, // Set of session IDs currently processing
- onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
- onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
- onShowSettings, // Show tools settings panel
- autoExpandTools, // Auto-expand tool accordions
- showRawParameters, // Show raw parameters in tool accordions
- showThinking, // Show thinking/reasoning sections
- autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
- sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
- externalMessageUpdate // Trigger for external CLI updates to current session
-}) {
- const { t } = useTranslation();
- const [editingFile, setEditingFile] = useState(null);
- const [selectedTask, setSelectedTask] = useState(null);
- const [showTaskDetail, setShowTaskDetail] = useState(false);
- const [editorWidth, setEditorWidth] = useState(600);
- const [isResizing, setIsResizing] = useState(false);
- const [editorExpanded, setEditorExpanded] = useState(false);
- const resizeRef = useRef(null);
-
- // PRD Editor state
- const [showPRDEditor, setShowPRDEditor] = useState(false);
- const [selectedPRD, setSelectedPRD] = useState(null);
- const [existingPRDs, setExistingPRDs] = useState([]);
- const [prdNotification, setPRDNotification] = useState(null);
-
- // TaskMaster context
- const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster();
- const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings();
-
- // Only show tasks tab if TaskMaster is installed and enabled
- const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled;
-
- // Sync selectedProject with TaskMaster context
- useEffect(() => {
- if (selectedProject && selectedProject !== currentProject) {
- setCurrentProject(selectedProject);
- }
- }, [selectedProject, currentProject, setCurrentProject]);
-
- // Switch away from tasks tab when tasks are disabled or TaskMaster is not installed
- useEffect(() => {
- if (!shouldShowTasksTab && activeTab === 'tasks') {
- setActiveTab('chat');
- }
- }, [shouldShowTasksTab, activeTab, setActiveTab]);
-
- // Load existing PRDs when current project changes
- useEffect(() => {
- const loadExistingPRDs = async () => {
- if (!currentProject?.name) {
- setExistingPRDs([]);
- return;
- }
-
- try {
- const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
- if (response.ok) {
- const data = await response.json();
- setExistingPRDs(data.prdFiles || []);
- } else {
- setExistingPRDs([]);
- }
- } catch (error) {
- console.error('Failed to load existing PRDs:', error);
- setExistingPRDs([]);
- }
- };
-
- loadExistingPRDs();
- }, [currentProject?.name]);
-
- const handleFileOpen = (filePath, diffInfo = null) => {
- // Create a file object that CodeEditor expects
- const file = {
- name: filePath.split('/').pop(),
- path: filePath,
- projectName: selectedProject?.name,
- diffInfo: diffInfo // Pass along diff information if available
- };
- setEditingFile(file);
- };
-
- const handleCloseEditor = () => {
- setEditingFile(null);
- setEditorExpanded(false);
- };
-
- const handleToggleEditorExpand = () => {
- setEditorExpanded(!editorExpanded);
- };
-
- const handleTaskClick = (task) => {
- // If task is just an ID (from dependency click), find the full task object
- if (typeof task === 'object' && task.id && !task.title) {
- const fullTask = tasks?.find(t => t.id === task.id);
- if (fullTask) {
- setSelectedTask(fullTask);
- setShowTaskDetail(true);
- }
- } else {
- setSelectedTask(task);
- setShowTaskDetail(true);
- }
- };
-
- const handleTaskDetailClose = () => {
- setShowTaskDetail(false);
- setSelectedTask(null);
- };
-
- const handleTaskStatusChange = (taskId, newStatus) => {
- // This would integrate with TaskMaster API to update task status
- console.log('Update task status:', taskId, newStatus);
- refreshTasks?.();
- };
-
- // Handle resize functionality
- const handleMouseDown = (e) => {
- if (isMobile) return; // Disable resize on mobile
- setIsResizing(true);
- e.preventDefault();
- };
-
- useEffect(() => {
- const handleMouseMove = (e) => {
- if (!isResizing) return;
-
- const container = resizeRef.current?.parentElement;
- if (!container) return;
-
- const containerRect = container.getBoundingClientRect();
- const newWidth = containerRect.right - e.clientX;
-
- // Min width: 300px, Max width: 80% of container
- const minWidth = 300;
- const maxWidth = containerRect.width * 0.8;
-
- if (newWidth >= minWidth && newWidth <= maxWidth) {
- setEditorWidth(newWidth);
- }
- };
-
- const handleMouseUp = () => {
- setIsResizing(false);
- };
-
- if (isResizing) {
- document.addEventListener('mousemove', handleMouseMove);
- document.addEventListener('mouseup', handleMouseUp);
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- }
-
- return () => {
- document.removeEventListener('mousemove', handleMouseMove);
- document.removeEventListener('mouseup', handleMouseUp);
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- };
- }, [isResizing]);
-
- if (isLoading) {
- return (
-
- {/* Header with menu button for mobile */}
- {isMobile && (
-
- )}
-
-
-
-
{t('mainContent.loading')}
-
{t('mainContent.settingUpWorkspace')}
-
-
-
- );
- }
-
- if (!selectedProject) {
- return (
-
- {/* Header with menu button for mobile */}
- {isMobile && (
-
- )}
-
-
-
-
{t('mainContent.chooseProject')}
-
- {t('mainContent.selectProjectDescription')}
-
-
-
- 💡 {t('mainContent.tip')}: {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
-
-
-
-
-
- );
- }
-
- return (
-
- {/* Header with tabs */}
-
-
-
- {isMobile && (
-
{
- e.preventDefault();
- onMenuClick();
- }}
- className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0"
- >
-
-
-
-
- )}
-
- {activeTab === 'chat' && selectedSession && (
-
- {selectedSession.__provider === 'cursor' ? (
-
- ) : (
-
- )}
-
- )}
-
- {activeTab === 'chat' && selectedSession ? (
-
-
- {selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
-
-
- {selectedProject.displayName}
-
-
- ) : activeTab === 'chat' && !selectedSession ? (
-
-
- {t('mainContent.newSession')}
-
-
- {selectedProject.displayName}
-
-
- ) : (
-
-
- {activeTab === 'files' ? t('mainContent.projectFiles') :
- activeTab === 'git' ? t('tabs.git') :
- (activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
- 'Project'}
-
-
- {selectedProject.displayName}
-
-
- )}
-
-
-
-
- {/* Modern Tab Navigation - Right Side */}
-
-
-
- setActiveTab('chat')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
- activeTab === 'chat'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- {t('tabs.chat')}
-
-
-
-
- setActiveTab('shell')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
- activeTab === 'shell'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- {t('tabs.shell')}
-
-
-
-
- setActiveTab('files')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
- activeTab === 'files'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- {t('tabs.files')}
-
-
-
-
- setActiveTab('git')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
- activeTab === 'git'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- {t('tabs.git')}
-
-
-
- {shouldShowTasksTab && (
-
- setActiveTab('tasks')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
- activeTab === 'tasks'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- {t('tabs.tasks')}
-
-
-
- )}
- {/*
setActiveTab('preview')}
- className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
- activeTab === 'preview'
- ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
- }`}
- >
-
-
-
-
- Preview
-
- */}
-
-
-
-
-
- {/* Content Area with Right Sidebar */}
-
- {/* Main Content */}
-
-
-
- setActiveTab('tasks') : null}
- />
-
-
- {activeTab === 'files' && (
-
-
-
- )}
- {activeTab === 'shell' && (
-
-
-
- )}
- {activeTab === 'git' && (
-
-
-
- )}
- {shouldShowTasksTab && (
-
-
- {
- setSelectedPRD(prd);
- setShowPRDEditor(true);
- }}
- existingPRDs={existingPRDs}
- onRefreshPRDs={(showNotification = false) => {
- // Reload existing PRDs
- if (currentProject?.name) {
- api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
- .then(response => response.ok ? response.json() : Promise.reject())
- .then(data => {
- setExistingPRDs(data.prdFiles || []);
- if (showNotification) {
- setPRDNotification('PRD saved successfully!');
- setTimeout(() => setPRDNotification(null), 3000);
- }
- })
- .catch(error => console.error('Failed to refresh PRDs:', error));
- }
- }}
- />
-
-
- )}
-
- {/* {
- sendMessage({
- type: 'server:start',
- projectPath: selectedProject?.fullPath,
- script: script
- });
- }}
- onStopServer={() => {
- sendMessage({
- type: 'server:stop',
- projectPath: selectedProject?.fullPath
- });
- }}
- onScriptSelect={setCurrentScript}
- currentScript={currentScript}
- isMobile={isMobile}
- serverLogs={serverLogs}
- onClearLogs={() => setServerLogs([])}
- /> */}
-
-
-
- {/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
- {editingFile && !isMobile && (
- <>
- {/* Resize Handle - Hidden when expanded */}
- {!editorExpanded && (
-
- {/* Visual indicator on hover */}
-
-
- )}
-
- {/* Editor Sidebar */}
-
-
-
- >
- )}
-
-
- {/* Code Editor Modal for Mobile */}
- {editingFile && isMobile && (
-
- )}
-
- {/* Task Detail Modal */}
- {shouldShowTasksTab && showTaskDetail && selectedTask && (
-
- )}
- {/* PRD Editor Modal */}
- {showPRDEditor && (
-
{
- setShowPRDEditor(false);
- setSelectedPRD(null);
- }}
- isNewFile={!selectedPRD?.isExisting}
- file={{
- name: selectedPRD?.name || 'prd.txt',
- content: selectedPRD?.content || ''
- }}
- onSave={async () => {
- setShowPRDEditor(false);
- setSelectedPRD(null);
-
- // Reload existing PRDs with notification
- try {
- const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
- if (response.ok) {
- const data = await response.json();
- setExistingPRDs(data.prdFiles || []);
- setPRDNotification('PRD saved successfully!');
- setTimeout(() => setPRDNotification(null), 3000);
- }
- } catch (error) {
- console.error('Failed to refresh PRDs:', error);
- }
-
- refreshTasks?.();
- }}
- />
- )}
- {/* PRD Notification */}
- {prdNotification && (
-
-
-
-
-
-
{prdNotification}
-
-
- )}
-
- );
-}
-
-export default React.memo(MainContent);
\ No newline at end of file
diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx
index 6da0289..fc69d49 100644
--- a/src/components/QuickSettingsPanel.jsx
+++ b/src/components/QuickSettingsPanel.jsx
@@ -17,31 +17,27 @@ import {
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle';
+
+import { useUiPreferences } from '../hooks/useUiPreferences';
import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
-const QuickSettingsPanel = ({
- isOpen,
- onToggle,
- autoExpandTools,
- onAutoExpandChange,
- showRawParameters,
- onShowRawParametersChange,
- showThinking,
- onShowThinkingChange,
- autoScrollToBottom,
- onAutoScrollChange,
- sendByCtrlEnter,
- onSendByCtrlEnterChange,
- isMobile
-}) => {
+import { useDeviceSettings } from '../hooks/useDeviceSettings';
+
+
+const QuickSettingsPanel = () => {
const { t } = useTranslation('settings');
- const [localIsOpen, setLocalIsOpen] = useState(isOpen);
+ const [isOpen, setIsOpen] = useState(false);
const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default';
});
const { isDarkMode } = useTheme();
+ const { isMobile } = useDeviceSettings({ trackPWA: false });
+
+ const { preferences, setPreference } = useUiPreferences();
+ const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
+
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
@@ -66,10 +62,6 @@ const QuickSettingsPanel = ({
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
- useEffect(() => {
- setLocalIsOpen(isOpen);
- }, [isOpen]);
-
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
@@ -206,9 +198,7 @@ const QuickSettingsPanel = ({
return;
}
- const newState = !localIsOpen;
- setLocalIsOpen(newState);
- onToggle(newState);
+ setIsOpen((previous) => !previous);
};
return (
@@ -226,19 +216,19 @@ const QuickSettingsPanel = ({
handleDragStart(e);
}}
className={`fixed ${
- localIsOpen ? 'right-64' : 'right-0'
+ isOpen ? 'right-64' : 'right-0'
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
- aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
+ aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
{isDragging ? (
- ) : localIsOpen ? (
+ ) : isOpen ? (
) : (
@@ -248,7 +238,7 @@ const QuickSettingsPanel = ({
{/* Panel */}
@@ -292,7 +282,7 @@ const QuickSettingsPanel = ({
onAutoExpandChange(e.target.checked)}
+ onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
@@ -305,7 +295,7 @@ const QuickSettingsPanel = ({
onShowRawParametersChange(e.target.checked)}
+ onChange={(e) => setPreference('showRawParameters', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
@@ -318,7 +308,7 @@ const QuickSettingsPanel = ({
onShowThinkingChange(e.target.checked)}
+ onChange={(e) => setPreference('showThinking', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
@@ -335,7 +325,7 @@ const QuickSettingsPanel = ({
onAutoScrollChange(e.target.checked)}
+ onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
@@ -353,7 +343,7 @@ const QuickSettingsPanel = ({
onSendByCtrlEnterChange(e.target.checked)}
+ onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
@@ -445,7 +435,7 @@ const QuickSettingsPanel = ({
{/* Backdrop */}
- {localIsOpen && (
+ {isOpen && (
;
+ }
+
+ if (provider === 'codex') {
+ return
;
+ }
+
+ return
;
+}
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
index 0d828a8..9517257 100644
--- a/src/components/Settings.jsx
+++ b/src/components/Settings.jsx
@@ -5,9 +5,6 @@ import { Badge } from './ui/badge';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key, GitBranch, Check } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
-import ClaudeLogo from './ClaudeLogo';
-import CursorLogo from './CursorLogo';
-import CodexLogo from './CodexLogo';
import CredentialsSettings from './CredentialsSettings';
import GitSettings from './GitSettings';
import TasksSettings from './TasksSettings';
diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx
index ca18a86..4e6dc3a 100644
--- a/src/components/Shell.jsx
+++ b/src/components/Shell.jsx
@@ -51,6 +51,12 @@ function fallbackCopyToClipboard(text) {
return copied;
}
+const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
+
+function isCodexLoginCommand(command) {
+ return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
+}
+
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null);
@@ -64,6 +70,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
const [isConnecting, setIsConnecting] = useState(false);
const [authUrl, setAuthUrl] = useState('');
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
+ const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
@@ -144,6 +151,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
@@ -190,11 +198,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
} else if (data.type === 'url_open') {
if (data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
}
}
} catch (error) {
@@ -206,6 +216,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
setIsConnected(false);
setIsConnecting(false);
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
if (terminal.current) {
terminal.current.clear();
@@ -245,6 +256,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
}, []);
const sessionDisplayName = useMemo(() => {
@@ -283,6 +295,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
setTimeout(() => {
setIsRestarting(false);
@@ -369,17 +382,21 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
terminal.current.open(terminalRef.current);
terminal.current.attachCustomKeyEventHandler((event) => {
+ const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
+ ? CODEX_DEVICE_AUTH_URL
+ : authUrlRef.current;
+
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
- authUrlRef.current &&
+ activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
- copyAuthUrlToClipboard(authUrlRef.current).catch(() => {});
+ copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
}
if (
@@ -497,18 +514,32 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
}
if (minimal) {
- const hasAuthUrl = Boolean(authUrl);
+ const displayAuthUrl = isCodexLoginCommand(initialCommand)
+ ? CODEX_DEVICE_AUTH_URL
+ : authUrl;
+ const hasAuthUrl = Boolean(displayAuthUrl);
+ const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
+ const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
- {hasAuthUrl && (
-
+ {showMobileAuthPanel && (
+
-
Open or copy the login URL:
+
+
Open or copy the login URL:
+
setIsAuthPanelHidden(true)}
+ className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
+ >
+ Hide
+
+
event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
@@ -518,7 +549,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{
- openAuthUrlInBrowser(authUrl);
+ openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
@@ -527,7 +558,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{
- const copied = await copyAuthUrlToClipboard(authUrl);
+ const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
@@ -538,6 +569,17 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
)}
+ {showMobileAuthPanelToggle && (
+
+ setIsAuthPanelHidden(false)}
+ className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
+ >
+ Show login URL
+
+
+ )}
);
}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
deleted file mode 100644
index a495cfc..0000000
--- a/src/components/Sidebar.jsx
+++ /dev/null
@@ -1,1547 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import ReactDOM from 'react-dom';
-import { ScrollArea } from './ui/scroll-area';
-import { Button } from './ui/button';
-import { Badge } from './ui/badge';
-import { Input } from './ui/input';
-import { useTranslation } from 'react-i18next';
-
-import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react';
-import { cn } from '../lib/utils';
-import ClaudeLogo from './ClaudeLogo';
-import CursorLogo from './CursorLogo.jsx';
-import CodexLogo from './CodexLogo.jsx';
-import TaskIndicator from './TaskIndicator';
-import ProjectCreationWizard from './ProjectCreationWizard';
-import { api } from '../utils/api';
-import { useTaskMaster } from '../contexts/TaskMasterContext';
-import { useTasksSettings } from '../contexts/TasksSettingsContext';
-import { IS_PLATFORM } from '../constants/config';
-
-// Move formatTimeAgo outside component to avoid recreation on every render
-const formatTimeAgo = (dateString, currentTime, t) => {
- const date = new Date(dateString);
- const now = currentTime;
-
- // Check if date is valid
- if (isNaN(date.getTime())) {
- return t ? t('status.unknown') : 'Unknown';
- }
-
- const diffInMs = now - date;
- const diffInSeconds = Math.floor(diffInMs / 1000);
- const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
- const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
- const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
-
- if (diffInSeconds < 60) return t ? t('time.justNow') : 'Just now';
- if (diffInMinutes === 1) return t ? t('time.oneMinuteAgo') : '1 min ago';
- if (diffInMinutes < 60) return t ? t('time.minutesAgo', { count: diffInMinutes }) : `${diffInMinutes} mins ago`;
- if (diffInHours === 1) return t ? t('time.oneHourAgo') : '1 hour ago';
- if (diffInHours < 24) return t ? t('time.hoursAgo', { count: diffInHours }) : `${diffInHours} hours ago`;
- if (diffInDays === 1) return t ? t('time.oneDayAgo') : '1 day ago';
- if (diffInDays < 7) return t ? t('time.daysAgo', { count: diffInDays }) : `${diffInDays} days ago`;
- return date.toLocaleDateString();
-};
-
-function Sidebar({
- projects,
- selectedProject,
- selectedSession,
- onProjectSelect,
- onSessionSelect,
- onNewSession,
- onSessionDelete,
- onProjectDelete,
- isLoading,
- loadingProgress,
- onRefresh,
- onShowSettings,
- updateAvailable,
- latestVersion,
- currentVersion,
- releaseInfo,
- onShowVersionModal,
- isPWA,
- isMobile,
- onToggleSidebar
-}) {
- const { t } = useTranslation('sidebar');
- const [expandedProjects, setExpandedProjects] = useState(new Set());
- const [editingProject, setEditingProject] = useState(null);
- const [showNewProject, setShowNewProject] = useState(false);
- const [editingName, setEditingName] = useState('');
- const [loadingSessions, setLoadingSessions] = useState({});
- const [additionalSessions, setAdditionalSessions] = useState({});
- const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
- const [currentTime, setCurrentTime] = useState(new Date());
- const [projectSortOrder, setProjectSortOrder] = useState('name');
- const [isRefreshing, setIsRefreshing] = useState(false);
- const [editingSession, setEditingSession] = useState(null);
- const [editingSessionName, setEditingSessionName] = useState('');
- const [generatingSummary, setGeneratingSummary] = useState({});
- const [searchFilter, setSearchFilter] = useState('');
- const [deletingProjects, setDeletingProjects] = useState(new Set());
- const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount }
- const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider }
-
- // TaskMaster context
- const { setCurrentProject, mcpServerStatus } = useTaskMaster();
- const { tasksEnabled } = useTasksSettings();
-
-
- // Starred projects state - persisted in localStorage
- const [starredProjects, setStarredProjects] = useState(() => {
- try {
- const saved = localStorage.getItem('starredProjects');
- return saved ? new Set(JSON.parse(saved)) : new Set();
- } catch (error) {
- console.error('Error loading starred projects:', error);
- return new Set();
- }
- });
-
- // Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
- const handleTouchClick = (callback) => {
- return (e) => {
- // Only prevent default for buttons/clickable elements, not scrollable areas
- if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- callback();
- };
- };
-
- // Auto-update timestamps every minute
- useEffect(() => {
- const timer = setInterval(() => {
- setCurrentTime(new Date());
- }, 60000); // Update every 60 seconds
-
- return () => clearInterval(timer);
- }, []);
-
- // Clear additional sessions when projects list changes (e.g., after refresh)
- useEffect(() => {
- setAdditionalSessions({});
- setInitialSessionsLoaded(new Set());
- }, [projects]);
-
- // Auto-expand project folder when a session is selected
- useEffect(() => {
- if (selectedSession && selectedProject) {
- setExpandedProjects(prev => new Set([...prev, selectedProject.name]));
- }
- }, [selectedSession, selectedProject]);
-
- // Mark sessions as loaded when projects come in
- useEffect(() => {
- if (projects.length > 0 && !isLoading) {
- const newLoaded = new Set();
- projects.forEach(project => {
- if (project.sessions && project.sessions.length >= 0) {
- newLoaded.add(project.name);
- }
- });
- setInitialSessionsLoaded(newLoaded);
- }
- }, [projects, isLoading]);
-
- // Load project sort order from settings
- useEffect(() => {
- const loadSortOrder = () => {
- try {
- const savedSettings = localStorage.getItem('claude-settings');
- if (savedSettings) {
- const settings = JSON.parse(savedSettings);
- setProjectSortOrder(settings.projectSortOrder || 'name');
- }
- } catch (error) {
- console.error('Error loading sort order:', error);
- }
- };
-
- // Load initially
- loadSortOrder();
-
- // Listen for storage changes
- const handleStorageChange = (e) => {
- if (e.key === 'claude-settings') {
- loadSortOrder();
- }
- };
-
- window.addEventListener('storage', handleStorageChange);
-
- // Also check periodically when component is focused (for same-tab changes)
- const checkInterval = setInterval(() => {
- if (document.hasFocus()) {
- loadSortOrder();
- }
- }, 1000);
-
- return () => {
- window.removeEventListener('storage', handleStorageChange);
- clearInterval(checkInterval);
- };
- }, []);
-
-
- const toggleProject = (projectName) => {
- const newExpanded = new Set();
- // If clicking the already-expanded project, collapse it (newExpanded stays empty)
- // If clicking a different project, expand only that one
- if (!expandedProjects.has(projectName)) {
- newExpanded.add(projectName);
- }
- setExpandedProjects(newExpanded);
- };
-
- // Wrapper to attach project context when session is clicked
- const handleSessionClick = (session, projectName) => {
- onSessionSelect({ ...session, __projectName: projectName });
- };
-
- // Starred projects utility functions
- const toggleStarProject = (projectName) => {
- const newStarred = new Set(starredProjects);
- if (newStarred.has(projectName)) {
- newStarred.delete(projectName);
- } else {
- newStarred.add(projectName);
- }
- setStarredProjects(newStarred);
-
- // Persist to localStorage
- try {
- localStorage.setItem('starredProjects', JSON.stringify([...newStarred]));
- } catch (error) {
- console.error('Error saving starred projects:', error);
- }
- };
-
- const isProjectStarred = (projectName) => {
- return starredProjects.has(projectName);
- };
-
- // Helper function to get all sessions for a project (initial + additional)
- const getAllSessions = (project) => {
- // Combine Claude, Cursor, and Codex sessions; Sidebar will display icon per row
- const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
- const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
- const codexSessions = (project.codexSessions || []).map(s => ({ ...s, __provider: 'codex' }));
- // Sort by most recent activity/date
- const normalizeDate = (s) => {
- if (s.__provider === 'cursor') return new Date(s.createdAt);
- if (s.__provider === 'codex') return new Date(s.createdAt || s.lastActivity);
- return new Date(s.lastActivity);
- };
- return [...claudeSessions, ...cursorSessions, ...codexSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
- };
-
- // Helper function to get the last activity date for a project
- const getProjectLastActivity = (project) => {
- const allSessions = getAllSessions(project);
- if (allSessions.length === 0) {
- return new Date(0); // Return epoch date for projects with no sessions
- }
-
- // Find the most recent session activity
- const mostRecentDate = allSessions.reduce((latest, session) => {
- const sessionDate = new Date(session.lastActivity);
- return sessionDate > latest ? sessionDate : latest;
- }, new Date(0));
-
- return mostRecentDate;
- };
-
- // Combined sorting: starred projects first, then by selected order
- const sortedProjects = [...projects].sort((a, b) => {
- const aStarred = isProjectStarred(a.name);
- const bStarred = isProjectStarred(b.name);
-
- // First, sort by starred status
- if (aStarred && !bStarred) return -1;
- if (!aStarred && bStarred) return 1;
-
- // For projects with same starred status, sort by selected order
- if (projectSortOrder === 'date') {
- // Sort by most recent activity (descending)
- return getProjectLastActivity(b) - getProjectLastActivity(a);
- } else {
- // Sort by display name (user-defined) or fallback to name (ascending)
- const nameA = a.displayName || a.name;
- const nameB = b.displayName || b.name;
- return nameA.localeCompare(nameB);
- }
- });
-
- const startEditing = (project) => {
- setEditingProject(project.name);
- setEditingName(project.displayName);
- };
-
- const cancelEditing = () => {
- setEditingProject(null);
- setEditingName('');
- };
-
- const saveProjectName = async (projectName) => {
- try {
- const response = await api.renameProject(projectName, editingName);
-
- if (response.ok) {
- // Refresh projects to get updated data
- if (window.refreshProjects) {
- window.refreshProjects();
- } else {
- window.location.reload();
- }
- } else {
- console.error('Failed to rename project');
- }
- } catch (error) {
- console.error('Error renaming project:', error);
- }
-
- setEditingProject(null);
- setEditingName('');
- };
-
- const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
- setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
- };
-
- const confirmDeleteSession = async () => {
- if (!sessionDeleteConfirmation) return;
-
- const { projectName, sessionId, provider } = sessionDeleteConfirmation;
- setSessionDeleteConfirmation(null);
-
- try {
- console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
-
- // Call the appropriate API based on provider
- let response;
- if (provider === 'codex') {
- response = await api.deleteCodexSession(sessionId);
- } else {
- response = await api.deleteSession(projectName, sessionId);
- }
-
- console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status });
-
- if (response.ok) {
- console.log('[Sidebar] Session deleted successfully, calling callback');
- // Call parent callback if provided
- if (onSessionDelete) {
- onSessionDelete(sessionId);
- } else {
- console.warn('[Sidebar] No onSessionDelete callback provided');
- }
- } else {
- const errorText = await response.text();
- console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
- alert(t('messages.deleteSessionFailed'));
- }
- } catch (error) {
- console.error('[Sidebar] Error deleting session:', error);
- alert(t('messages.deleteSessionError'));
- }
- };
-
- const deleteProject = (project) => {
- const sessionCount = getAllSessions(project).length;
- setDeleteConfirmation({ project, sessionCount });
- };
-
- const confirmDeleteProject = async () => {
- if (!deleteConfirmation) return;
-
- const { project, sessionCount } = deleteConfirmation;
- const isEmpty = sessionCount === 0;
-
- setDeleteConfirmation(null);
- setDeletingProjects(prev => new Set([...prev, project.name]));
-
- try {
- const response = await api.deleteProject(project.name, !isEmpty);
-
- if (response.ok) {
- if (onProjectDelete) {
- onProjectDelete(project.name);
- }
- } else {
- const error = await response.json();
- console.error('Failed to delete project');
- alert(error.error || t('messages.deleteProjectFailed'));
- }
- } catch (error) {
- console.error('Error deleting project:', error);
- alert(t('messages.deleteProjectError'));
- } finally {
- setDeletingProjects(prev => {
- const next = new Set(prev);
- next.delete(project.name);
- return next;
- });
- }
- };
-
- const createNewProject = async () => {
- if (!newProjectPath.trim()) {
- alert(t('messages.enterProjectPath'));
- return;
- }
-
- setCreatingProject(true);
-
- try {
- const response = await api.createProject(newProjectPath.trim());
-
- if (response.ok) {
- const result = await response.json();
-
- // Save the path to recent paths before clearing
- saveToRecentPaths(newProjectPath.trim());
-
- setShowNewProject(false);
- setNewProjectPath('');
-
- // Refresh projects to show the new one
- if (window.refreshProjects) {
- window.refreshProjects();
- } else {
- window.location.reload();
- }
- } else {
- const error = await response.json();
- alert(error.error || t('messages.createProjectFailed'));
- }
- } catch (error) {
- console.error('Error creating project:', error);
- alert(t('messages.createProjectError'));
- } finally {
- setCreatingProject(false);
- }
- };
-
- const cancelNewProject = () => {
- setShowNewProject(false);
- setNewProjectPath('');
- };
-
- const loadMoreSessions = async (project) => {
- // Check if we can load more sessions
- const canLoadMore = project.sessionMeta?.hasMore !== false;
-
- if (!canLoadMore || loadingSessions[project.name]) {
- return;
- }
-
- setLoadingSessions(prev => ({ ...prev, [project.name]: true }));
-
- try {
- const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
- const response = await api.sessions(project.name, 5, currentSessionCount);
-
- if (response.ok) {
- const result = await response.json();
-
- // Store additional sessions locally
- setAdditionalSessions(prev => ({
- ...prev,
- [project.name]: [
- ...(prev[project.name] || []),
- ...result.sessions
- ]
- }));
-
- // Update project metadata if needed
- if (result.hasMore === false) {
- // Mark that there are no more sessions to load
- project.sessionMeta = { ...project.sessionMeta, hasMore: false };
- }
- }
- } catch (error) {
- console.error('Error loading more sessions:', error);
- } finally {
- setLoadingSessions(prev => ({ ...prev, [project.name]: false }));
- }
- };
-
- // Filter projects based on search input
- const filteredProjects = sortedProjects.filter(project => {
- if (!searchFilter.trim()) return true;
-
- const searchLower = searchFilter.toLowerCase();
- const displayName = (project.displayName || project.name).toLowerCase();
- const projectName = project.name.toLowerCase();
-
- // Search in both display name and actual project name/path
- return displayName.includes(searchLower) || projectName.includes(searchLower);
- });
-
- // Enhanced project selection that updates both the main UI and TaskMaster context
- const handleProjectSelect = (project) => {
- // Call the original project select handler
- onProjectSelect(project);
-
- // Update TaskMaster context with the selected project
- setCurrentProject(project);
- };
-
- return (
- <>
- {/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
- {showNewProject && ReactDOM.createPortal(
-
setShowNewProject(false)}
- onProjectCreated={(project) => {
- // Refresh projects list after creation
- if (window.refreshProjects) {
- window.refreshProjects();
- } else {
- window.location.reload();
- }
- }}
- />,
- document.body
- )}
-
- {/* Delete Confirmation Modal */}
- {deleteConfirmation && ReactDOM.createPortal(
-
-
-
-
-
-
-
- {t('deleteConfirmation.deleteProject')}
-
-
- {t('deleteConfirmation.confirmDelete')}{' '}
-
- {deleteConfirmation.project.displayName || deleteConfirmation.project.name}
- ?
-
- {deleteConfirmation.sessionCount > 0 && (
-
-
- {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
-
-
- {t('deleteConfirmation.allConversationsDeleted')}
-
-
- )}
-
- {t('deleteConfirmation.cannotUndo')}
-
-
-
-
-
- setDeleteConfirmation(null)}
- >
- {t('actions.cancel')}
-
-
-
- {t('actions.delete')}
-
-
-
-
,
- document.body
- )}
-
- {/* Session Delete Confirmation Modal */}
- {sessionDeleteConfirmation && ReactDOM.createPortal(
-
-
-
-
-
-
-
- {t('deleteConfirmation.deleteSession')}
-
-
- {t('deleteConfirmation.confirmDelete')}{' '}
-
- {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
- ?
-
-
- {t('deleteConfirmation.cannotUndo')}
-
-
-
-
-
- setSessionDeleteConfirmation(null)}
- >
- {t('actions.cancel')}
-
-
-
- {t('actions.delete')}
-
-
-
-
,
- document.body
- )}
-
-
- {/* Header */}
-
- {/* Desktop Header */}
-
-
- {/* Mobile Header */}
-
-
- {IS_PLATFORM ? (
-
-
-
-
-
-
{t('app.title')}
-
{t('projects.title')}
-
-
- ) : (
-
-
-
-
-
-
{t('app.title')}
-
{t('projects.title')}
-
-
- )}
-
- {
- setIsRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setIsRefreshing(false);
- }
- }}
- disabled={isRefreshing}
- >
-
-
- setShowNewProject(true)}
- >
-
-
-
-
-
-
-
- {/* Action Buttons - Desktop only - Always show when not loading */}
- {!isLoading && !isMobile && (
-
-
- setShowNewProject(true)}
- title={t('tooltips.createProject')}
- >
-
- {t('projects.newProject')}
-
- {
- setIsRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setIsRefreshing(false);
- }
- }}
- disabled={isRefreshing}
- title={t('tooltips.refresh')}
- >
-
-
-
-
- )}
-
- {/* Search Filter - Only show when there are projects */}
- {projects.length > 0 && !isLoading && (
-
-
-
- setSearchFilter(e.target.value)}
- className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
- />
- {searchFilter && (
- setSearchFilter('')}
- className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
- >
-
-
- )}
-
-
- )}
-
- {/* Projects List */}
-
-
- {isLoading ? (
-
-
-
{t('projects.loadingProjects')}
-
- {t('projects.fetchingProjects')}
-
-
{t('projects.loadingProjects')}
- {loadingProgress && loadingProgress.total > 0 ? (
-
-
-
- {loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
-
- {loadingProgress.currentProject && (
-
- {loadingProgress.currentProject.split('-').slice(-2).join('/')}
-
- )}
-
- ) : (
-
- {t('projects.fetchingProjects')}
-
- )}
-
- ) : projects.length === 0 ? (
-
-
-
-
-
{t('projects.noProjects')}
-
- {t('projects.runClaudeCli')}
-
-
- ) : filteredProjects.length === 0 ? (
-
-
-
-
-
{t('projects.noMatchingProjects')}
-
- {t('projects.tryDifferentSearch')}
-
-
- ) : (
- filteredProjects.map((project) => {
- const isExpanded = expandedProjects.has(project.name);
- const isSelected = selectedProject?.name === project.name;
- const isStarred = isProjectStarred(project.name);
- const isDeleting = deletingProjects.has(project.name);
-
- return (
-
- {/* Project Header */}
-
- {/* Mobile Project Item */}
-
-
{
- // On mobile, just toggle the folder - don't select the project
- toggleProject(project.name);
- }}
- onTouchEnd={handleTouchClick(() => toggleProject(project.name))}
- >
-
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
- {editingProject === project.name ? (
-
setEditingName(e.target.value)}
- className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
- placeholder={t('projects.projectNamePlaceholder')}
- autoFocus
- autoComplete="off"
- onClick={(e) => e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === 'Enter') saveProjectName(project.name);
- if (e.key === 'Escape') cancelEditing();
- }}
- style={{
- fontSize: '16px', // Prevents zoom on iOS
- WebkitAppearance: 'none',
- borderRadius: '8px'
- }}
- />
- ) : (
- <>
-
-
- {project.displayName}
-
- {tasksEnabled && (
- {
- const projectConfigured = project.taskmaster?.hasTaskmaster;
- const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
- if (projectConfigured && mcpConfigured) return 'fully-configured';
- if (projectConfigured) return 'taskmaster-only';
- if (mcpConfigured) return 'mcp-only';
- return 'not-configured';
- })()}
- size="xs"
- className="hidden md:inline-flex flex-shrink-0 ml-2"
- />
- )}
-
-
- {(() => {
- const sessionCount = getAllSessions(project).length;
- const hasMore = project.sessionMeta?.hasMore !== false;
- const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
- return `${count} session${count === 1 ? '' : 's'}`;
- })()}
-
- >
- )}
-
-
-
- {editingProject === project.name ? (
- <>
-
{
- e.stopPropagation();
- saveProjectName(project.name);
- }}
- >
-
-
-
{
- e.stopPropagation();
- cancelEditing();
- }}
- >
-
-
- >
- ) : (
- <>
- {/* Star button */}
-
{
- e.stopPropagation();
- toggleStarProject(project.name);
- }}
- onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
- title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
- >
-
-
-
{
- e.stopPropagation();
- deleteProject(project);
- }}
- onTouchEnd={handleTouchClick(() => deleteProject(project))}
- >
-
-
-
{
- e.stopPropagation();
- startEditing(project);
- }}
- onTouchEnd={handleTouchClick(() => startEditing(project))}
- >
-
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
- >
- )}
-
-
-
-
-
- {/* Desktop Project Item */}
-
{
- // Desktop behavior: select project and toggle
- if (selectedProject?.name !== project.name) {
- handleProjectSelect(project);
- }
- toggleProject(project.name);
- }}
- onTouchEnd={handleTouchClick(() => {
- if (selectedProject?.name !== project.name) {
- handleProjectSelect(project);
- }
- toggleProject(project.name);
- })}
- >
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
- {editingProject === project.name ? (
-
-
setEditingName(e.target.value)}
- className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
- placeholder={t('projects.projectNamePlaceholder')}
- autoFocus
- onKeyDown={(e) => {
- if (e.key === 'Enter') saveProjectName(project.name);
- if (e.key === 'Escape') cancelEditing();
- }}
- />
-
- {project.fullPath}
-
-
- ) : (
-
-
- {project.displayName}
-
-
- {(() => {
- const sessionCount = getAllSessions(project).length;
- const hasMore = project.sessionMeta?.hasMore !== false;
- return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
- })()}
- {project.fullPath !== project.displayName && (
-
- • {project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
-
- )}
-
-
- )}
-
-
-
-
- {editingProject === project.name ? (
- <>
-
{
- e.stopPropagation();
- saveProjectName(project.name);
- }}
- >
-
-
-
{
- e.stopPropagation();
- cancelEditing();
- }}
- >
-
-
- >
- ) : (
- <>
- {/* Star button */}
-
{
- e.stopPropagation();
- toggleStarProject(project.name);
- }}
- title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
- >
-
-
-
{
- e.stopPropagation();
- startEditing(project);
- }}
- title={t('tooltips.renameProject')}
- >
-
-
-
{
- e.stopPropagation();
- deleteProject(project);
- }}
- title={t('tooltips.deleteProject')}
- >
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
- >
- )}
-
-
-
-
- {/* Sessions List */}
- {isExpanded && (
-
- {!initialSessionsLoaded.has(project.name) ? (
- // Loading skeleton for sessions
- Array.from({ length: 3 }).map((_, i) => (
-
- ))
- ) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
-
-
{t('sessions.noSessions')}
-
- ) : (
- getAllSessions(project).map((session) => {
- // Handle Claude, Cursor, and Codex session formats
- const isCursorSession = session.__provider === 'cursor';
- const isCodexSession = session.__provider === 'codex';
-
- // Calculate if session is active (within last 10 minutes)
- const getSessionDate = () => {
- if (isCursorSession) return new Date(session.createdAt);
- if (isCodexSession) return new Date(session.createdAt || session.lastActivity);
- return new Date(session.lastActivity);
- };
- const sessionDate = getSessionDate();
- const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
- const isActive = diffInMinutes < 10;
-
- // Get session display values
- const getSessionName = () => {
- if (isCursorSession) return session.name || t('projects.untitledSession');
- if (isCodexSession) return session.summary || session.name || t('projects.codexSession');
- return session.summary || t('projects.newSession');
- };
- const sessionName = getSessionName();
- const getSessionTime = () => {
- if (isCursorSession) return session.createdAt;
- if (isCodexSession) return session.createdAt || session.lastActivity;
- return session.lastActivity;
- };
- const sessionTime = getSessionTime();
- const messageCount = session.messageCount || 0;
-
- return (
-
- {/* Active session indicator dot */}
- {isActive && (
-
- )}
- {/* Mobile Session Item */}
-
-
{
- handleProjectSelect(project);
- handleSessionClick(session, project.name);
- }}
- onTouchEnd={handleTouchClick(() => {
- handleProjectSelect(project);
- handleSessionClick(session, project.name);
- })}
- >
-
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
-
- {sessionName}
-
-
-
-
- {formatTimeAgo(sessionTime, currentTime, t)}
-
- {messageCount > 0 && (
-
- {messageCount}
-
- )}
- {/* Provider tiny icon */}
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
-
- {!isCursorSession && (
-
{
- e.stopPropagation();
- showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
- }}
- onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
- >
-
-
- )}
-
-
-
-
- {/* Desktop Session Item */}
-
-
handleSessionClick(session, project.name)}
- onTouchEnd={handleTouchClick(() => handleSessionClick(session, project.name))}
- >
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
- {sessionName}
-
-
-
-
- {formatTimeAgo(sessionTime, currentTime, t)}
-
- {messageCount > 0 && (
-
- {messageCount}
-
- )}
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {!isCursorSession && (
-
- {editingSession === session.id && !isCodexSession ? (
- <>
- setEditingSessionName(e.target.value)}
- onKeyDown={(e) => {
- e.stopPropagation();
- if (e.key === 'Enter') {
- updateSessionSummary(project.name, session.id, editingSessionName);
- } else if (e.key === 'Escape') {
- setEditingSession(null);
- setEditingSessionName('');
- }
- }}
- onClick={(e) => e.stopPropagation()}
- className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
- autoFocus
- />
- {
- e.stopPropagation();
- updateSessionSummary(project.name, session.id, editingSessionName);
- }}
- title={t('tooltips.save')}
- >
-
-
- {
- e.stopPropagation();
- setEditingSession(null);
- setEditingSessionName('');
- }}
- title={t('tooltips.cancel')}
- >
-
-
- >
- ) : (
- <>
- {!isCodexSession && (
- {
- e.stopPropagation();
- setEditingSession(session.id);
- setEditingSessionName(session.summary || t('projects.newSession'));
- }}
- title={t('tooltips.editSessionName')}
- >
-
-
- )}
- {
- e.stopPropagation();
- showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
- }}
- title={t('tooltips.deleteSession')}
- >
-
-
- >
- )}
-
- )}
-
-
- );
- })
- )}
-
- {/* Show More Sessions Button */}
- {getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
-
loadMoreSessions(project)}
- disabled={loadingSessions[project.name]}
- >
- {loadingSessions[project.name] ? (
- <>
-
- {t('sessions.loading')}
- >
- ) : (
- <>
-
- {t('sessions.showMore')}
- >
- )}
-
- )}
-
- {/* Sessions - New Session Button */}
-
-
{
- handleProjectSelect(project);
- onNewSession(project);
- }}
- >
-
- {t('sessions.newSession')}
-
-
-
-
onNewSession(project)}
- >
-
- {t('sessions.newSession')}
-
-
- )}
-
- );
- })
- )}
-
-
-
- {/* Version Update Notification */}
- {updateAvailable && (
-
- {/* Desktop Version Notification */}
-
-
-
-
-
- {releaseInfo?.title || `Version ${latestVersion}`}
-
-
{t('version.updateAvailable')}
-
-
-
-
- {/* Mobile Version Notification */}
-
-
-
-
-
- {releaseInfo?.title || `Version ${latestVersion}`}
-
-
{t('version.updateAvailable')}
-
-
-
-
- )}
-
- {/* Settings Section */}
-
- {/* Mobile Settings */}
-
-
-
-
-
- {t('actions.settings')}
-
-
-
- {/* Desktop Settings */}
-
-
- {t('actions.settings')}
-
-
-
- >
- );
-}
-
-export default Sidebar;
diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx
index e9028f0..8876c15 100644
--- a/src/components/TodoList.jsx
+++ b/src/components/TodoList.jsx
@@ -10,12 +10,12 @@ const TodoList = ({ todos, isResult = false }) => {
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
- return ;
+ return ;
case 'in_progress':
- return ;
+ return ;
case 'pending':
default:
- return ;
+ return ;
}
};
@@ -44,38 +44,38 @@ const TodoList = ({ todos, isResult = false }) => {
};
return (
-
+
{isResult && (
-
+
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
)}
-
+
{todos.map((todo, index) => (
{getStatusIcon(todo.status)}
-
+
-
-
+
+
{todo.content}
-
+
{todo.priority}
{todo.status.replace('_', ' ')}
@@ -88,4 +88,4 @@ const TodoList = ({ todos, isResult = false }) => {
);
};
-export default TodoList;
\ No newline at end of file
+export default TodoList;
diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx
new file mode 100644
index 0000000..df2f818
--- /dev/null
+++ b/src/components/app/AppContent.tsx
@@ -0,0 +1,144 @@
+import { useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import Sidebar from '../sidebar/view/Sidebar';
+import MainContent from '../main-content/view/MainContent';
+import MobileNav from '../MobileNav';
+
+import { useWebSocket } from '../../contexts/WebSocketContext';
+import { useDeviceSettings } from '../../hooks/useDeviceSettings';
+import { useSessionProtection } from '../../hooks/useSessionProtection';
+import { useProjectsState } from '../../hooks/useProjectsState';
+
+export default function AppContent() {
+ const navigate = useNavigate();
+ const { sessionId } = useParams<{ sessionId?: string }>();
+ const { t } = useTranslation('common');
+ const { isMobile } = useDeviceSettings({ trackPWA: false });
+ const { ws, sendMessage, latestMessage } = useWebSocket();
+
+ const {
+ activeSessions,
+ processingSessions,
+ markSessionAsActive,
+ markSessionAsInactive,
+ markSessionAsProcessing,
+ markSessionAsNotProcessing,
+ replaceTemporarySession,
+ } = useSessionProtection();
+
+ const {
+ selectedProject,
+ selectedSession,
+ activeTab,
+ sidebarOpen,
+ isLoadingProjects,
+ isInputFocused,
+ externalMessageUpdate,
+ setActiveTab,
+ setSidebarOpen,
+ setIsInputFocused,
+ setShowSettings,
+ openSettings,
+ fetchProjects,
+ sidebarSharedProps,
+ } = useProjectsState({
+ sessionId,
+ navigate,
+ latestMessage,
+ isMobile,
+ activeSessions,
+ });
+
+ useEffect(() => {
+ window.refreshProjects = fetchProjects;
+
+ return () => {
+ if (window.refreshProjects === fetchProjects) {
+ delete window.refreshProjects;
+ }
+ };
+ }, [fetchProjects]);
+
+ useEffect(() => {
+ window.openSettings = openSettings;
+
+ return () => {
+ if (window.openSettings === openSettings) {
+ delete window.openSettings;
+ }
+ };
+ }, [openSettings]);
+
+ return (
+
+ {!isMobile ? (
+
+
+
+ ) : (
+
+
{
+ event.stopPropagation();
+ setSidebarOpen(false);
+ }}
+ onTouchStart={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setSidebarOpen(false);
+ }}
+ aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
+ />
+ event.stopPropagation()}
+ onTouchStart={(event) => event.stopPropagation()}
+ >
+
+
+
+ )}
+
+
+ setSidebarOpen(true)}
+ isLoading={isLoadingProjects}
+ onInputFocusChange={setIsInputFocused}
+ onSessionActive={markSessionAsActive}
+ onSessionInactive={markSessionAsInactive}
+ onSessionProcessing={markSessionAsProcessing}
+ onSessionNotProcessing={markSessionAsNotProcessing}
+ processingSessions={processingSessions}
+ onReplaceTemporarySession={replaceTemporarySession}
+ onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}
+ onShowSettings={() => setShowSettings(true)}
+ externalMessageUpdate={externalMessageUpdate}
+ />
+
+
+ {isMobile && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/chat/constants/thinkingModes.ts b/src/components/chat/constants/thinkingModes.ts
new file mode 100644
index 0000000..3b28131
--- /dev/null
+++ b/src/components/chat/constants/thinkingModes.ts
@@ -0,0 +1,44 @@
+import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
+
+export const thinkingModes = [
+ {
+ id: 'none',
+ name: 'Standard',
+ description: 'Regular Claude response',
+ icon: null,
+ prefix: '',
+ color: 'text-gray-600'
+ },
+ {
+ id: 'think',
+ name: 'Think',
+ description: 'Basic extended thinking',
+ icon: Brain,
+ prefix: 'think',
+ color: 'text-blue-600'
+ },
+ {
+ id: 'think-hard',
+ name: 'Think Hard',
+ description: 'More thorough evaluation',
+ icon: Zap,
+ prefix: 'think hard',
+ color: 'text-purple-600'
+ },
+ {
+ id: 'think-harder',
+ name: 'Think Harder',
+ description: 'Deep analysis with alternatives',
+ icon: Sparkles,
+ prefix: 'think harder',
+ color: 'text-indigo-600'
+ },
+ {
+ id: 'ultrathink',
+ name: 'Ultrathink',
+ description: 'Maximum thinking budget',
+ icon: Atom,
+ prefix: 'ultrathink',
+ color: 'text-red-600'
+ }
+];
\ No newline at end of file
diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts
new file mode 100644
index 0000000..e9e4c9c
--- /dev/null
+++ b/src/components/chat/hooks/useChatComposerState.ts
@@ -0,0 +1,957 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type {
+ ChangeEvent,
+ ClipboardEvent,
+ Dispatch,
+ FormEvent,
+ KeyboardEvent,
+ MouseEvent,
+ SetStateAction,
+ TouchEvent,
+} from 'react';
+import { useDropzone } from 'react-dropzone';
+import { authenticatedFetch } from '../../../utils/api';
+
+import { thinkingModes } from '../constants/thinkingModes';
+
+import { grantClaudeToolPermission } from '../utils/chatPermissions';
+import { safeLocalStorage } from '../utils/chatStorage';
+import type {
+ ChatMessage,
+ PendingPermissionRequest,
+ PermissionMode,
+} from '../types/types';
+import { useFileMentions } from './useFileMentions';
+import { type SlashCommand, useSlashCommands } from './useSlashCommands';
+import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
+import { escapeRegExp } from '../utils/chatFormatting';
+
+type PendingViewSession = {
+ sessionId: string | null;
+ startedAt: number;
+};
+
+interface UseChatComposerStateArgs {
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ currentSessionId: string | null;
+ provider: SessionProvider;
+ permissionMode: PermissionMode | string;
+ cyclePermissionMode: () => void;
+ cursorModel: string;
+ claudeModel: string;
+ codexModel: string;
+ isLoading: boolean;
+ canAbortSession: boolean;
+ tokenBudget: Record
| null;
+ sendMessage: (message: unknown) => void;
+ sendByCtrlEnter?: boolean;
+ onSessionActive?: (sessionId?: string | null) => void;
+ onInputFocusChange?: (focused: boolean) => void;
+ onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
+ onShowSettings?: () => void;
+ pendingViewSessionRef: { current: PendingViewSession | null };
+ scrollToBottom: () => void;
+ setChatMessages: Dispatch>;
+ setSessionMessages?: Dispatch>;
+ setIsLoading: (loading: boolean) => void;
+ setCanAbortSession: (canAbort: boolean) => void;
+ setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
+ setIsUserScrolledUp: (isScrolledUp: boolean) => void;
+ setPendingPermissionRequests: Dispatch>;
+}
+
+interface MentionableFile {
+ name: string;
+ path: string;
+}
+
+interface CommandExecutionResult {
+ type: 'builtin' | 'custom';
+ action?: string;
+ data?: any;
+ content?: string;
+ hasBashCommands?: boolean;
+ hasFileIncludes?: boolean;
+}
+
+const createFakeSubmitEvent = () => {
+ return { preventDefault: () => undefined } as unknown as FormEvent;
+};
+
+const isTemporarySessionId = (sessionId: string | null | undefined) =>
+ Boolean(sessionId && sessionId.startsWith('new-session-'));
+
+export function useChatComposerState({
+ selectedProject,
+ selectedSession,
+ currentSessionId,
+ provider,
+ permissionMode,
+ cyclePermissionMode,
+ cursorModel,
+ claudeModel,
+ codexModel,
+ isLoading,
+ canAbortSession,
+ tokenBudget,
+ sendMessage,
+ sendByCtrlEnter,
+ onSessionActive,
+ onInputFocusChange,
+ onFileOpen,
+ onShowSettings,
+ pendingViewSessionRef,
+ scrollToBottom,
+ setChatMessages,
+ setSessionMessages,
+ setIsLoading,
+ setCanAbortSession,
+ setClaudeStatus,
+ setIsUserScrolledUp,
+ setPendingPermissionRequests,
+}: UseChatComposerStateArgs) {
+ const [input, setInput] = useState(() => {
+ if (typeof window !== 'undefined' && selectedProject) {
+ return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
+ }
+ return '';
+ });
+ const [attachedImages, setAttachedImages] = useState([]);
+ const [uploadingImages, setUploadingImages] = useState>(new Map());
+ const [imageErrors, setImageErrors] = useState>(new Map());
+ const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
+ const [thinkingMode, setThinkingMode] = useState('none');
+
+ const textareaRef = useRef(null);
+ const inputHighlightRef = useRef(null);
+ const handleSubmitRef = useRef<
+ ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | null
+ >(null);
+ const inputValueRef = useRef(input);
+
+ const handleBuiltInCommand = useCallback(
+ (result: CommandExecutionResult) => {
+ const { action, data } = result;
+ switch (action) {
+ case 'clear':
+ setChatMessages([]);
+ setSessionMessages?.([]);
+ break;
+
+ case 'help':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: data.content,
+ timestamp: Date.now(),
+ },
+ ]);
+ break;
+
+ case 'model':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: '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((previous) => [
+ ...previous,
+ { type: '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((previous) => [
+ ...previous,
+ { type: 'assistant', content: statusMessage, timestamp: Date.now() },
+ ]);
+ break;
+ }
+
+ case 'memory':
+ if (data.error) {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: `⚠️ ${data.message}`,
+ timestamp: Date.now(),
+ },
+ ]);
+ } else {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
+ timestamp: Date.now(),
+ },
+ ]);
+ if (data.exists && onFileOpen) {
+ onFileOpen(data.path);
+ }
+ }
+ break;
+
+ case 'config':
+ onShowSettings?.();
+ break;
+
+ case 'rewind':
+ if (data.error) {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: `⚠️ ${data.message}`,
+ timestamp: Date.now(),
+ },
+ ]);
+ } else {
+ setChatMessages((previous) => previous.slice(0, -data.steps * 2));
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: `⏪ ${data.message}`,
+ timestamp: Date.now(),
+ },
+ ]);
+ }
+ break;
+
+ default:
+ console.warn('Unknown built-in command action:', action);
+ }
+ },
+ [onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
+ );
+
+ const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
+ const { content, hasBashCommands } = result;
+
+ if (hasBashCommands) {
+ const confirmed = window.confirm(
+ 'This command contains bash commands that will be executed. Do you want to proceed?',
+ );
+ if (!confirmed) {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: '❌ Command execution cancelled',
+ timestamp: Date.now(),
+ },
+ ]);
+ return;
+ }
+ }
+
+ const commandContent = content || '';
+ setInput(commandContent);
+ inputValueRef.current = commandContent;
+
+ // Defer submit to next tick so the command text is reflected in UI before dispatching.
+ setTimeout(() => {
+ if (handleSubmitRef.current) {
+ handleSubmitRef.current(createFakeSubmitEvent());
+ }
+ }, 0);
+ }, [setChatMessages]);
+
+ const executeCommand = useCallback(
+ async (command: SlashCommand) => {
+ if (!command || !selectedProject) {
+ return;
+ }
+
+ try {
+ const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
+ const args =
+ commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
+
+ const context = {
+ projectPath: selectedProject.fullPath || selectedProject.path,
+ projectName: selectedProject.name,
+ sessionId: currentSessionId,
+ provider,
+ model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
+ tokenUsage: tokenBudget,
+ };
+
+ 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) {
+ let errorMessage = `Failed to execute command (${response.status})`;
+ try {
+ const errorData = await response.json();
+ errorMessage = errorData?.message || errorData?.error || errorMessage;
+ } catch {
+ // Ignore JSON parse failures and use fallback message.
+ }
+ throw new Error(errorMessage);
+ }
+
+ const result = (await response.json()) as CommandExecutionResult;
+ if (result.type === 'builtin') {
+ handleBuiltInCommand(result);
+ setInput('');
+ inputValueRef.current = '';
+ } else if (result.type === 'custom') {
+ await handleCustomCommand(result);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Error executing command:', error);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: `Error executing command: ${message}`,
+ timestamp: Date.now(),
+ },
+ ]);
+ }
+ },
+ [
+ claudeModel,
+ codexModel,
+ currentSessionId,
+ cursorModel,
+ handleBuiltInCommand,
+ handleCustomCommand,
+ input,
+ provider,
+ selectedProject,
+ setChatMessages,
+ tokenBudget,
+ ],
+ );
+
+ const {
+ slashCommandsCount,
+ filteredCommands,
+ frequentCommands,
+ commandQuery,
+ showCommandMenu,
+ selectedCommandIndex,
+ resetCommandMenuState,
+ handleCommandSelect,
+ handleToggleCommandMenu,
+ handleCommandInputChange,
+ handleCommandMenuKeyDown,
+ } = useSlashCommands({
+ selectedProject,
+ input,
+ setInput,
+ textareaRef,
+ onExecuteCommand: executeCommand,
+ });
+
+ const {
+ showFileDropdown,
+ filteredFiles,
+ selectedFileIndex,
+ renderInputWithMentions,
+ selectFile,
+ setCursorPosition,
+ handleFileMentionsKeyDown,
+ } = useFileMentions({
+ selectedProject,
+ input,
+ setInput,
+ textareaRef,
+ });
+
+ const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {
+ if (!inputHighlightRef.current || !target) {
+ return;
+ }
+ inputHighlightRef.current.scrollTop = target.scrollTop;
+ inputHighlightRef.current.scrollLeft = target.scrollLeft;
+ }, []);
+
+ const handleImageFiles = useCallback((files: File[]) => {
+ const validFiles = files.filter((file) => {
+ try {
+ 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) {
+ const fileName = file.name || 'Unknown file';
+ setImageErrors((previous) => {
+ const next = new Map(previous);
+ next.set(fileName, 'File too large (max 5MB)');
+ return next;
+ });
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error validating file:', error, file);
+ return false;
+ }
+ });
+
+ if (validFiles.length > 0) {
+ setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));
+ }
+ }, []);
+
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ const items = Array.from(event.clipboardData.items);
+
+ items.forEach((item) => {
+ if (!item.type.startsWith('image/')) {
+ return;
+ }
+ const file = item.getAsFile();
+ if (file) {
+ handleImageFiles([file]);
+ }
+ });
+
+ if (items.length === 0 && event.clipboardData.files.length > 0) {
+ const files = Array.from(event.clipboardData.files);
+ const imageFiles = files.filter((file) => file.type.startsWith('image/'));
+ if (imageFiles.length > 0) {
+ handleImageFiles(imageFiles);
+ }
+ }
+ },
+ [handleImageFiles],
+ );
+
+ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
+ accept: {
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
+ },
+ maxSize: 5 * 1024 * 1024,
+ maxFiles: 5,
+ onDrop: handleImageFiles,
+ noClick: true,
+ noKeyboard: true,
+ });
+
+ const handleSubmit = useCallback(
+ async (
+ event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent,
+ ) => {
+ event.preventDefault();
+ const currentInput = inputValueRef.current;
+ if (!currentInput.trim() || isLoading || !selectedProject) {
+ return;
+ }
+
+ let messageContent = currentInput;
+ const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
+ if (selectedThinkingMode && selectedThinkingMode.prefix) {
+ messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
+ }
+
+ let uploadedImages: unknown[] = [];
+ 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: {},
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to upload images');
+ }
+
+ const result = await response.json();
+ uploadedImages = result.images;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Image upload failed:', error);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: `Failed to upload images: ${message}`,
+ timestamp: new Date(),
+ },
+ ]);
+ return;
+ }
+ }
+
+ const userMessage: ChatMessage = {
+ type: 'user',
+ content: currentInput,
+ images: uploadedImages as any,
+ timestamp: new Date(),
+ };
+
+ setChatMessages((previous) => [...previous, userMessage]);
+ setIsLoading(true);
+ setCanAbortSession(true);
+ setClaudeStatus({
+ text: 'Processing',
+ tokens: 0,
+ can_interrupt: true,
+ });
+
+ setIsUserScrolledUp(false);
+ setTimeout(() => scrollToBottom(), 100);
+
+ const effectiveSessionId =
+ currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
+ const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
+
+ if (!effectiveSessionId && !selectedSession?.id) {
+ if (typeof window !== 'undefined') {
+ // Reset stale pending IDs from previous interrupted runs before creating a new one.
+ sessionStorage.removeItem('pendingSessionId');
+ }
+ pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
+ }
+ onSessionActive?.(sessionToActivate);
+
+ 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();
+ const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
+
+ if (provider === 'cursor') {
+ sendMessage({
+ type: 'cursor-command',
+ command: messageContent,
+ sessionId: effectiveSessionId,
+ options: {
+ cwd: resolvedProjectPath,
+ projectPath: resolvedProjectPath,
+ sessionId: effectiveSessionId,
+ resume: Boolean(effectiveSessionId),
+ model: cursorModel,
+ skipPermissions: toolsSettings?.skipPermissions || false,
+ toolsSettings,
+ },
+ });
+ } else if (provider === 'codex') {
+ sendMessage({
+ type: 'codex-command',
+ command: messageContent,
+ sessionId: effectiveSessionId,
+ options: {
+ cwd: resolvedProjectPath,
+ projectPath: resolvedProjectPath,
+ sessionId: effectiveSessionId,
+ resume: Boolean(effectiveSessionId),
+ model: codexModel,
+ permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
+ },
+ });
+ } else {
+ sendMessage({
+ type: 'claude-command',
+ command: messageContent,
+ options: {
+ projectPath: resolvedProjectPath,
+ cwd: resolvedProjectPath,
+ sessionId: effectiveSessionId,
+ resume: Boolean(effectiveSessionId),
+ toolsSettings,
+ permissionMode,
+ model: claudeModel,
+ images: uploadedImages,
+ },
+ });
+ }
+
+ setInput('');
+ inputValueRef.current = '';
+ resetCommandMenuState();
+ setAttachedImages([]);
+ setUploadingImages(new Map());
+ setImageErrors(new Map());
+ setIsTextareaExpanded(false);
+ setThinkingMode('none');
+
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ }
+
+ safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
+ },
+ [
+ attachedImages,
+ claudeModel,
+ codexModel,
+ currentSessionId,
+ cursorModel,
+ isLoading,
+ onSessionActive,
+ pendingViewSessionRef,
+ permissionMode,
+ provider,
+ resetCommandMenuState,
+ scrollToBottom,
+ selectedProject,
+ selectedSession?.id,
+ sendMessage,
+ setCanAbortSession,
+ setChatMessages,
+ setClaudeStatus,
+ setIsLoading,
+ setIsUserScrolledUp,
+ thinkingMode,
+ ],
+ );
+
+ useEffect(() => {
+ handleSubmitRef.current = handleSubmit;
+ }, [handleSubmit]);
+
+ useEffect(() => {
+ inputValueRef.current = input;
+ }, [input]);
+
+ useEffect(() => {
+ if (!selectedProject) {
+ return;
+ }
+ const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
+ setInput((previous) => {
+ const next = previous === savedInput ? previous : savedInput;
+ inputValueRef.current = next;
+ return next;
+ });
+ }, [selectedProject?.name]);
+
+ useEffect(() => {
+ if (!selectedProject) {
+ return;
+ }
+ if (input !== '') {
+ safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
+ } else {
+ safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
+ }
+ }, [input, selectedProject]);
+
+ useEffect(() => {
+ if (!textareaRef.current) {
+ return;
+ }
+ // Re-run when input changes so restored drafts get the same autosize behavior as typed text.
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
+ const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
+ const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
+ setIsTextareaExpanded(expanded);
+ }, [input]);
+
+ useEffect(() => {
+ if (!textareaRef.current || input.trim()) {
+ return;
+ }
+ textareaRef.current.style.height = 'auto';
+ setIsTextareaExpanded(false);
+ }, [input]);
+
+ const handleInputChange = useCallback(
+ (event: ChangeEvent) => {
+ const newValue = event.target.value;
+ const cursorPos = event.target.selectionStart;
+
+ setInput(newValue);
+ inputValueRef.current = newValue;
+ setCursorPosition(cursorPos);
+
+ if (!newValue.trim()) {
+ event.target.style.height = 'auto';
+ setIsTextareaExpanded(false);
+ resetCommandMenuState();
+ return;
+ }
+
+ handleCommandInputChange(newValue, cursorPos);
+ },
+ [handleCommandInputChange, resetCommandMenuState, setCursorPosition],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (handleCommandMenuKeyDown(event)) {
+ return;
+ }
+
+ if (handleFileMentionsKeyDown(event)) {
+ return;
+ }
+
+ if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
+ event.preventDefault();
+ cyclePermissionMode();
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ if (event.nativeEvent.isComposing) {
+ return;
+ }
+
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit(event);
+ } else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {
+ event.preventDefault();
+ handleSubmit(event);
+ }
+ }
+ },
+ [
+ cyclePermissionMode,
+ handleCommandMenuKeyDown,
+ handleFileMentionsKeyDown,
+ handleSubmit,
+ sendByCtrlEnter,
+ showCommandMenu,
+ showFileDropdown,
+ ],
+ );
+
+ const handleTextareaClick = useCallback(
+ (event: MouseEvent) => {
+ setCursorPosition(event.currentTarget.selectionStart);
+ },
+ [setCursorPosition],
+ );
+
+ const handleTextareaInput = useCallback(
+ (event: FormEvent) => {
+ const target = event.currentTarget;
+ target.style.height = 'auto';
+ target.style.height = `${target.scrollHeight}px`;
+ setCursorPosition(target.selectionStart);
+ syncInputOverlayScroll(target);
+
+ const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
+ setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
+ },
+ [setCursorPosition, syncInputOverlayScroll],
+ );
+
+ const handleClearInput = useCallback(() => {
+ setInput('');
+ inputValueRef.current = '';
+ resetCommandMenuState();
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.focus();
+ }
+ setIsTextareaExpanded(false);
+ }, [resetCommandMenuState]);
+
+ const handleAbortSession = useCallback(() => {
+ if (!canAbortSession) {
+ return;
+ }
+
+ const pendingSessionId =
+ typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
+ const cursorSessionId =
+ typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
+
+ const candidateSessionIds = [
+ currentSessionId,
+ pendingViewSessionRef.current?.sessionId || null,
+ pendingSessionId,
+ provider === 'cursor' ? cursorSessionId : null,
+ selectedSession?.id || null,
+ ];
+
+ const targetSessionId =
+ candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
+
+ if (!targetSessionId) {
+ console.warn('Abort requested but no concrete session ID is available yet.');
+ return;
+ }
+
+ sendMessage({
+ type: 'abort-session',
+ sessionId: targetSessionId,
+ provider,
+ });
+ }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
+
+ const handleTranscript = useCallback((text: string) => {
+ if (!text.trim()) {
+ return;
+ }
+
+ setInput((previousInput) => {
+ const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
+ inputValueRef.current = newInput;
+
+ setTimeout(() => {
+ if (!textareaRef.current) {
+ return;
+ }
+
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
+ const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
+ setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
+ }, 0);
+
+ return newInput;
+ });
+ }, []);
+
+ const handleGrantToolPermission = useCallback(
+ (suggestion: { entry: string; toolName: string }) => {
+ if (!suggestion || provider !== 'claude') {
+ return { success: false };
+ }
+ return grantClaudeToolPermission(suggestion.entry);
+ },
+ [provider],
+ );
+
+ const handlePermissionDecision = useCallback(
+ (
+ requestIds: string | string[],
+ decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
+ ) => {
+ 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((previous) => {
+ const next = previous.filter((request) => !validIds.includes(request.requestId));
+ if (next.length === 0) {
+ setClaudeStatus(null);
+ }
+ return next;
+ });
+ },
+ [sendMessage, setClaudeStatus, setPendingPermissionRequests],
+ );
+
+ const handleInputFocusChange = useCallback(
+ (focused: boolean) => {
+ onInputFocusChange?.(focused);
+ },
+ [onInputFocusChange],
+ );
+
+ return {
+ input,
+ setInput,
+ textareaRef,
+ inputHighlightRef,
+ isTextareaExpanded,
+ thinkingMode,
+ setThinkingMode,
+ slashCommandsCount,
+ filteredCommands,
+ frequentCommands,
+ commandQuery,
+ showCommandMenu,
+ selectedCommandIndex,
+ resetCommandMenuState,
+ handleCommandSelect,
+ handleToggleCommandMenu,
+ showFileDropdown,
+ filteredFiles: filteredFiles as MentionableFile[],
+ selectedFileIndex,
+ renderInputWithMentions,
+ selectFile,
+ attachedImages,
+ setAttachedImages,
+ uploadingImages,
+ imageErrors,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ openImagePicker: open,
+ handleSubmit,
+ handleInputChange,
+ handleKeyDown,
+ handlePaste,
+ handleTextareaClick,
+ handleTextareaInput,
+ syncInputOverlayScroll,
+ handleClearInput,
+ handleAbortSession,
+ handleTranscript,
+ handlePermissionDecision,
+ handleGrantToolPermission,
+ handleInputFocusChange,
+ };
+}
diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts
new file mode 100644
index 0000000..e2d98e5
--- /dev/null
+++ b/src/components/chat/hooks/useChatProviderState.ts
@@ -0,0 +1,114 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { authenticatedFetch } from '../../../utils/api';
+import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
+import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
+import type { ProjectSession, SessionProvider } from '../../../types/app';
+
+interface UseChatProviderStateArgs {
+ selectedSession: ProjectSession | null;
+}
+
+export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
+ const [permissionMode, setPermissionMode] = useState('default');
+ const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
+ const [provider, setProvider] = useState(() => {
+ return (localStorage.getItem('selected-provider') as SessionProvider) || '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;
+ });
+
+ const lastProviderRef = useRef(provider);
+
+ useEffect(() => {
+ if (!selectedSession?.id) {
+ return;
+ }
+
+ const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
+ setPermissionMode((savedMode as PermissionMode) || 'default');
+ }, [selectedSession?.id]);
+
+ useEffect(() => {
+ if (!selectedSession?.__provider || selectedSession.__provider === provider) {
+ return;
+ }
+
+ setProvider(selectedSession.__provider);
+ localStorage.setItem('selected-provider', selectedSession.__provider);
+ }, [provider, selectedSession]);
+
+ useEffect(() => {
+ if (lastProviderRef.current === provider) {
+ return;
+ }
+ setPendingPermissionRequests([]);
+ lastProviderRef.current = provider;
+ }, [provider]);
+
+ useEffect(() => {
+ setPendingPermissionRequests((previous) =>
+ previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
+ );
+ }, [selectedSession?.id]);
+
+ useEffect(() => {
+ if (provider !== 'cursor') {
+ return;
+ }
+
+ authenticatedFetch('/api/cursor/config')
+ .then((response) => response.json())
+ .then((data) => {
+ if (!data.success || !data.config?.model?.modelId) {
+ return;
+ }
+
+ const modelId = data.config.model.modelId as string;
+ if (!localStorage.getItem('cursor-model')) {
+ setCursorModel(modelId);
+ }
+ })
+ .catch((error) => {
+ console.error('Error loading Cursor config:', error);
+ });
+ }, [provider]);
+
+ const cyclePermissionMode = useCallback(() => {
+ const modes: PermissionMode[] =
+ provider === 'codex'
+ ? ['default', 'acceptEdits', 'bypassPermissions']
+ : ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
+
+ const currentIndex = modes.indexOf(permissionMode);
+ const nextIndex = (currentIndex + 1) % modes.length;
+ const nextMode = modes[nextIndex];
+ setPermissionMode(nextMode);
+
+ if (selectedSession?.id) {
+ localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
+ }
+ }, [permissionMode, provider, selectedSession?.id]);
+
+ return {
+ provider,
+ setProvider,
+ cursorModel,
+ setCursorModel,
+ claudeModel,
+ setClaudeModel,
+ codexModel,
+ setCodexModel,
+ permissionMode,
+ setPermissionMode,
+ pendingPermissionRequests,
+ setPendingPermissionRequests,
+ cyclePermissionMode,
+ };
+}
diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts
new file mode 100644
index 0000000..9d2071b
--- /dev/null
+++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts
@@ -0,0 +1,956 @@
+import { useEffect, useRef } from 'react';
+import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
+import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
+import { safeLocalStorage } from '../utils/chatStorage';
+import type { ChatMessage, PendingPermissionRequest } from '../types/types';
+import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
+
+type PendingViewSession = {
+ sessionId: string | null;
+ startedAt: number;
+};
+
+type LatestChatMessage = {
+ type?: string;
+ data?: any;
+ sessionId?: string;
+ requestId?: string;
+ toolName?: string;
+ input?: unknown;
+ context?: unknown;
+ error?: string;
+ tool?: string;
+ exitCode?: number;
+ isProcessing?: boolean;
+ actualSessionId?: string;
+ [key: string]: any;
+};
+
+interface UseChatRealtimeHandlersArgs {
+ latestMessage: LatestChatMessage | null;
+ provider: SessionProvider;
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ currentSessionId: string | null;
+ setCurrentSessionId: (sessionId: string | null) => void;
+ setChatMessages: Dispatch>;
+ setIsLoading: (loading: boolean) => void;
+ setCanAbortSession: (canAbort: boolean) => void;
+ setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
+ setTokenBudget: (budget: Record | null) => void;
+ setIsSystemSessionChange: (isSystemSessionChange: boolean) => void;
+ setPendingPermissionRequests: Dispatch>;
+ pendingViewSessionRef: MutableRefObject;
+ streamBufferRef: MutableRefObject;
+ streamTimerRef: MutableRefObject;
+ onSessionInactive?: (sessionId?: string | null) => void;
+ onSessionProcessing?: (sessionId?: string | null) => void;
+ onSessionNotProcessing?: (sessionId?: string | null) => void;
+ onReplaceTemporarySession?: (sessionId?: string | null) => void;
+ onNavigateToSession?: (sessionId: string) => void;
+}
+
+const appendStreamingChunk = (
+ setChatMessages: Dispatch>,
+ chunk: string,
+ newline = false,
+) => {
+ if (!chunk) {
+ return;
+ }
+
+ setChatMessages((previous) => {
+ const updated = [...previous];
+ const lastIndex = updated.length - 1;
+ const last = updated[lastIndex];
+ if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
+ const nextContent = newline
+ ? last.content
+ ? `${last.content}\n${chunk}`
+ : chunk
+ : `${last.content || ''}${chunk}`;
+ // Clone the message instead of mutating in place so React can reliably detect state updates.
+ updated[lastIndex] = { ...last, content: nextContent };
+ } else {
+ updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
+ }
+ return updated;
+ });
+};
+
+const finalizeStreamingMessage = (setChatMessages: Dispatch>) => {
+ setChatMessages((previous) => {
+ const updated = [...previous];
+ const lastIndex = updated.length - 1;
+ const last = updated[lastIndex];
+ if (last && last.type === 'assistant' && last.isStreaming) {
+ // Clone the message instead of mutating in place so React can reliably detect state updates.
+ updated[lastIndex] = { ...last, isStreaming: false };
+ }
+ return updated;
+ });
+};
+
+export function useChatRealtimeHandlers({
+ latestMessage,
+ provider,
+ selectedProject,
+ selectedSession,
+ currentSessionId,
+ setCurrentSessionId,
+ setChatMessages,
+ setIsLoading,
+ setCanAbortSession,
+ setClaudeStatus,
+ setTokenBudget,
+ setIsSystemSessionChange,
+ setPendingPermissionRequests,
+ pendingViewSessionRef,
+ streamBufferRef,
+ streamTimerRef,
+ onSessionInactive,
+ onSessionProcessing,
+ onSessionNotProcessing,
+ onReplaceTemporarySession,
+ onNavigateToSession,
+}: UseChatRealtimeHandlersArgs) {
+ const lastProcessedMessageRef = useRef(null);
+
+ useEffect(() => {
+ if (!latestMessage) {
+ return;
+ }
+
+ // Guard against duplicate processing when dependency updates occur without a new message object.
+ if (lastProcessedMessageRef.current === latestMessage) {
+ return;
+ }
+ lastProcessedMessageRef.current = latestMessage;
+
+ const messageData = latestMessage.data?.message || latestMessage.data;
+ const structuredMessageData =
+ messageData && typeof messageData === 'object' ? (messageData as Record) : null;
+ const rawStructuredData =
+ latestMessage.data && typeof latestMessage.data === 'object'
+ ? (latestMessage.data as Record)
+ : null;
+
+ const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
+ const isGlobalMessage = globalMessageTypes.includes(String(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' &&
+ structuredMessageData &&
+ structuredMessageData.type === 'system' &&
+ structuredMessageData.subtype === 'init';
+
+ const isCursorSystemInit =
+ latestMessage.type === 'cursor-system' &&
+ rawStructuredData &&
+ rawStructuredData.type === 'system' &&
+ rawStructuredData.subtype === 'init';
+
+ const systemInitSessionId = isClaudeSystemInit
+ ? structuredMessageData?.session_id
+ : isCursorSystemInit
+ ? rawStructuredData?.session_id
+ : null;
+
+ const activeViewSessionId =
+ selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
+ const isSystemInitForView =
+ systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
+ const shouldBypassSessionFilter = isGlobalMessage || Boolean(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?: string) => {
+ if (!sessionId) {
+ return;
+ }
+ onSessionInactive?.(sessionId);
+ onSessionNotProcessing?.(sessionId);
+ };
+
+ const collectSessionIds = (...sessionIds: Array) =>
+ Array.from(
+ new Set(
+ sessionIds.filter((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0),
+ ),
+ );
+
+ const clearLoadingIndicators = () => {
+ setIsLoading(false);
+ setCanAbortSession(false);
+ setClaudeStatus(null);
+ };
+
+ const markSessionsAsCompleted = (...sessionIds: Array) => {
+ const normalizedSessionIds = collectSessionIds(...sessionIds);
+ normalizedSessionIds.forEach((sessionId) => {
+ onSessionInactive?.(sessionId);
+ onSessionNotProcessing?.(sessionId);
+ });
+ };
+
+ if (!shouldBypassSessionFilter) {
+ if (!activeViewSessionId) {
+ if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
+ handleBackgroundLifecycle(latestMessage.sessionId);
+ }
+ if (!isUnscopedError) {
+ return;
+ }
+ }
+
+ if (!latestMessage.sessionId && !isUnscopedError) {
+ return;
+ }
+
+ if (latestMessage.sessionId !== activeViewSessionId) {
+ if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
+ handleBackgroundLifecycle(latestMessage.sessionId);
+ }
+ console.log(
+ 'Skipping message for different session:',
+ latestMessage.sessionId,
+ 'current:',
+ activeViewSessionId,
+ );
+ return;
+ }
+ }
+
+ switch (latestMessage.type) {
+ case 'session-created':
+ if (latestMessage.sessionId && !currentSessionId) {
+ sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
+ if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
+ pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
+ }
+
+ setIsSystemSessionChange(true);
+ onReplaceTemporarySession?.(latestMessage.sessionId);
+
+ setPendingPermissionRequests((previous) =>
+ previous.map((request) =>
+ request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId },
+ ),
+ );
+ }
+ break;
+
+ case 'token-budget':
+ if (latestMessage.data) {
+ setTokenBudget(latestMessage.data);
+ }
+ break;
+
+ case 'claude-response': {
+ if (messageData && typeof messageData === 'object' && messageData.type) {
+ if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
+ const decodedText = decodeHtmlEntities(messageData.delta.text);
+ streamBufferRef.current += decodedText;
+ if (!streamTimerRef.current) {
+ streamTimerRef.current = window.setTimeout(() => {
+ const chunk = streamBufferRef.current;
+ streamBufferRef.current = '';
+ streamTimerRef.current = null;
+ appendStreamingChunk(setChatMessages, chunk, false);
+ }, 100);
+ }
+ return;
+ }
+
+ if (messageData.type === 'content_block_stop') {
+ if (streamTimerRef.current) {
+ clearTimeout(streamTimerRef.current);
+ streamTimerRef.current = null;
+ }
+ const chunk = streamBufferRef.current;
+ streamBufferRef.current = '';
+ appendStreamingChunk(setChatMessages, chunk, false);
+ finalizeStreamingMessage(setChatMessages);
+ return;
+ }
+ }
+
+ if (
+ structuredMessageData?.type === 'system' &&
+ structuredMessageData.subtype === 'init' &&
+ structuredMessageData.session_id &&
+ currentSessionId &&
+ structuredMessageData.session_id !== currentSessionId &&
+ isSystemInitForView
+ ) {
+ console.log('Claude CLI session duplication detected:', {
+ originalSession: currentSessionId,
+ newSession: structuredMessageData.session_id,
+ });
+
+ setIsSystemSessionChange(true);
+ onNavigateToSession?.(structuredMessageData.session_id);
+ return;
+ }
+
+ if (
+ structuredMessageData?.type === 'system' &&
+ structuredMessageData.subtype === 'init' &&
+ structuredMessageData.session_id &&
+ !currentSessionId &&
+ isSystemInitForView
+ ) {
+ console.log('New session init detected:', {
+ newSession: structuredMessageData.session_id,
+ });
+
+ setIsSystemSessionChange(true);
+ onNavigateToSession?.(structuredMessageData.session_id);
+ return;
+ }
+
+ if (
+ structuredMessageData?.type === 'system' &&
+ structuredMessageData.subtype === 'init' &&
+ structuredMessageData.session_id &&
+ currentSessionId &&
+ structuredMessageData.session_id === currentSessionId &&
+ isSystemInitForView
+ ) {
+ console.log('System init message for current session, ignoring');
+ return;
+ }
+
+ if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
+ structuredMessageData.content.forEach((part: any) => {
+ if (part.type === 'tool_use') {
+ const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ isToolUse: true,
+ toolName: part.name,
+ toolInput,
+ toolId: part.id,
+ toolResult: null,
+ },
+ ]);
+ return;
+ }
+
+ if (part.type === 'text' && part.text?.trim()) {
+ let content = decodeHtmlEntities(part.text);
+ content = formatUsageLimitText(content);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content,
+ timestamp: new Date(),
+ },
+ ]);
+ }
+ });
+ } else if (structuredMessageData && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) {
+ let content = decodeHtmlEntities(structuredMessageData.content);
+ content = formatUsageLimitText(content);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content,
+ timestamp: new Date(),
+ },
+ ]);
+ }
+
+ if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
+ structuredMessageData.content.forEach((part: any) => {
+ if (part.type !== 'tool_result') {
+ return;
+ }
+
+ setChatMessages((previous) =>
+ previous.map((message) => {
+ if (message.isToolUse && message.toolId === part.tool_use_id) {
+ return {
+ ...message,
+ toolResult: {
+ content: part.content,
+ isError: part.is_error,
+ timestamp: new Date(),
+ },
+ };
+ }
+ return message;
+ }),
+ );
+ });
+ }
+ break;
+ }
+
+ case 'claude-output': {
+ const cleaned = String(latestMessage.data || '');
+ if (cleaned.trim()) {
+ streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
+ if (!streamTimerRef.current) {
+ streamTimerRef.current = window.setTimeout(() => {
+ const chunk = streamBufferRef.current;
+ streamBufferRef.current = '';
+ streamTimerRef.current = null;
+ appendStreamingChunk(setChatMessages, chunk, true);
+ }, 100);
+ }
+ }
+ break;
+ }
+
+ case 'claude-interactive-prompt':
+ // Interactive prompts are parsed/rendered as text in the UI.
+ // Normalize to string to keep ChatMessage.content shape consistent.
+ {
+ const interactiveContent =
+ typeof latestMessage.data === 'string'
+ ? latestMessage.data
+ : JSON.stringify(latestMessage.data ?? '', null, 2);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: interactiveContent,
+ timestamp: new Date(),
+ isInteractivePrompt: true,
+ },
+ ]);
+ }
+ break;
+
+ case 'claude-permission-request':
+ if (provider !== 'claude' || !latestMessage.requestId) {
+ break;
+ }
+ {
+ const requestId = latestMessage.requestId;
+
+ setPendingPermissionRequests((previous) => {
+ if (previous.some((request) => request.requestId === requestId)) {
+ return previous;
+ }
+ return [
+ ...previous,
+ {
+ requestId,
+ toolName: latestMessage.toolName || 'UnknownTool',
+ input: latestMessage.input,
+ context: latestMessage.context,
+ sessionId: latestMessage.sessionId || null,
+ receivedAt: new Date(),
+ },
+ ];
+ });
+ }
+
+ setIsLoading(true);
+ setCanAbortSession(true);
+ setClaudeStatus({
+ text: 'Waiting for permission',
+ tokens: 0,
+ can_interrupt: true,
+ });
+ break;
+
+ case 'claude-permission-cancelled':
+ if (!latestMessage.requestId) {
+ break;
+ }
+ setPendingPermissionRequests((previous) =>
+ previous.filter((request) => request.requestId !== latestMessage.requestId),
+ );
+ break;
+
+ case 'claude-error':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: `Error: ${latestMessage.error}`,
+ timestamp: new Date(),
+ },
+ ]);
+ break;
+
+ case 'cursor-system':
+ try {
+ const cursorData = latestMessage.data;
+ if (
+ cursorData &&
+ cursorData.type === 'system' &&
+ cursorData.subtype === 'init' &&
+ cursorData.session_id
+ ) {
+ if (!isSystemInitForView) {
+ return;
+ }
+
+ if (currentSessionId && cursorData.session_id !== currentSessionId) {
+ console.log('Cursor session switch detected:', {
+ originalSession: currentSessionId,
+ newSession: cursorData.session_id,
+ });
+ setIsSystemSessionChange(true);
+ onNavigateToSession?.(cursorData.session_id);
+ return;
+ }
+
+ if (!currentSessionId) {
+ console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
+ setIsSystemSessionChange(true);
+ onNavigateToSession?.(cursorData.session_id);
+ return;
+ }
+ }
+ } catch (error) {
+ console.warn('Error handling cursor-system message:', error);
+ }
+ break;
+
+ case 'cursor-user':
+ break;
+
+ case 'cursor-tool-use':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ 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':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
+ timestamp: new Date(),
+ },
+ ]);
+ break;
+
+ case 'cursor-result': {
+ const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
+ const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
+
+ clearLoadingIndicators();
+ markSessionsAsCompleted(
+ cursorCompletedSessionId,
+ currentSessionId,
+ selectedSession?.id,
+ pendingCursorSessionId,
+ );
+
+ try {
+ const resultData = latestMessage.data || {};
+ const textResult = typeof resultData.result === 'string' ? resultData.result : '';
+
+ if (streamTimerRef.current) {
+ clearTimeout(streamTimerRef.current);
+ streamTimerRef.current = null;
+ }
+ const pendingChunk = streamBufferRef.current;
+ streamBufferRef.current = '';
+
+ setChatMessages((previous) => {
+ const updated = [...previous];
+ const lastIndex = updated.length - 1;
+ const last = updated[lastIndex];
+ if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
+ const finalContent =
+ textResult && textResult.trim()
+ ? textResult
+ : `${last.content || ''}${pendingChunk || ''}`;
+ // Clone the message instead of mutating in place so React can reliably detect state updates.
+ updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
+ } else if (textResult && textResult.trim()) {
+ updated.push({
+ type: resultData.is_error ? 'error' : 'assistant',
+ content: textResult,
+ timestamp: new Date(),
+ isStreaming: false,
+ });
+ }
+ return updated;
+ });
+ } catch (error) {
+ console.warn('Error handling cursor-result message:', error);
+ }
+
+ if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
+ setCurrentSessionId(cursorCompletedSessionId);
+ sessionStorage.removeItem('pendingSessionId');
+ if (window.refreshProjects) {
+ setTimeout(() => window.refreshProjects?.(), 500);
+ }
+ }
+ break;
+ }
+
+ case 'cursor-output':
+ 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 = window.setTimeout(() => {
+ const chunk = streamBufferRef.current;
+ streamBufferRef.current = '';
+ streamTimerRef.current = null;
+ appendStreamingChunk(setChatMessages, chunk, true);
+ }, 100);
+ }
+ }
+ } catch (error) {
+ console.warn('Error handling cursor-output message:', error);
+ }
+ break;
+
+ case 'claude-complete': {
+ const pendingSessionId = sessionStorage.getItem('pendingSessionId');
+ const completedSessionId =
+ latestMessage.sessionId || currentSessionId || pendingSessionId;
+
+ clearLoadingIndicators();
+ markSessionsAsCompleted(
+ completedSessionId,
+ currentSessionId,
+ selectedSession?.id,
+ pendingSessionId,
+ );
+
+ if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
+ setCurrentSessionId(pendingSessionId);
+ sessionStorage.removeItem('pendingSessionId');
+ console.log('New session complete, ID set to:', pendingSessionId);
+ }
+
+ if (selectedProject && latestMessage.exitCode === 0) {
+ safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
+ }
+ setPendingPermissionRequests([]);
+ break;
+ }
+
+ case 'codex-response': {
+ const codexData = latestMessage.data;
+ if (!codexData) {
+ break;
+ }
+
+ if (codexData.type === 'item') {
+ switch (codexData.itemType) {
+ case 'agent_message':
+ if (codexData.message?.content?.trim()) {
+ const content = decodeHtmlEntities(codexData.message.content);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content,
+ timestamp: new Date(),
+ },
+ ]);
+ }
+ break;
+
+ case 'reasoning':
+ if (codexData.message?.content?.trim()) {
+ const content = decodeHtmlEntities(codexData.message.content);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content,
+ timestamp: new Date(),
+ isThinking: true,
+ },
+ ]);
+ }
+ break;
+
+ case 'command_execution':
+ if (codexData.command) {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ 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((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`)
+ .join('\n');
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ isToolUse: true,
+ toolName: 'FileChanges',
+ toolInput: changesList,
+ toolResult: {
+ content: `Status: ${codexData.status}`,
+ isError: false,
+ },
+ },
+ ]);
+ }
+ break;
+
+ case 'mcp_tool_call':
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ 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((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: codexData.message.content,
+ timestamp: new Date(),
+ },
+ ]);
+ }
+ break;
+
+ default:
+ console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
+ }
+ }
+
+ if (codexData.type === 'turn_complete') {
+ clearLoadingIndicators();
+ markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
+ }
+
+ if (codexData.type === 'turn_failed') {
+ clearLoadingIndicators();
+ markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: codexData.error?.message || 'Turn failed',
+ timestamp: new Date(),
+ },
+ ]);
+ }
+ break;
+ }
+
+ case 'codex-complete': {
+ const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
+ const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
+ const codexCompletedSessionId =
+ latestMessage.sessionId || currentSessionId || codexPendingSessionId;
+
+ clearLoadingIndicators();
+ markSessionsAsCompleted(
+ codexCompletedSessionId,
+ codexActualSessionId,
+ currentSessionId,
+ selectedSession?.id,
+ codexPendingSessionId,
+ );
+
+ if (codexPendingSessionId && !currentSessionId) {
+ setCurrentSessionId(codexActualSessionId);
+ setIsSystemSessionChange(true);
+ if (codexActualSessionId) {
+ 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':
+ setIsLoading(false);
+ setCanAbortSession(false);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: latestMessage.error || 'An error occurred with Codex',
+ timestamp: new Date(),
+ },
+ ]);
+ break;
+
+ case 'session-aborted': {
+ const pendingSessionId =
+ typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
+ const abortedSessionId = latestMessage.sessionId || currentSessionId;
+ const abortSucceeded = latestMessage.success !== false;
+
+ if (abortSucceeded) {
+ clearLoadingIndicators();
+ markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
+ if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
+ sessionStorage.removeItem('pendingSessionId');
+ }
+
+ setPendingPermissionRequests([]);
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'assistant',
+ content: 'Session interrupted by user.',
+ timestamp: new Date(),
+ },
+ ]);
+ } else {
+ setChatMessages((previous) => [
+ ...previous,
+ {
+ type: 'error',
+ content: 'Stop request failed. The session is still running.',
+ timestamp: new Date(),
+ },
+ ]);
+ }
+ break;
+ }
+
+ case 'session-status': {
+ const statusSessionId = latestMessage.sessionId;
+ const isCurrentSession =
+ statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
+ if (isCurrentSession && latestMessage.isProcessing) {
+ setIsLoading(true);
+ setCanAbortSession(true);
+ onSessionProcessing?.(statusSessionId);
+ }
+ break;
+ }
+
+ case 'claude-status': {
+ const statusData = latestMessage.data;
+ if (!statusData) {
+ break;
+ }
+
+ const statusInfo: { text: string; tokens: number; can_interrupt: boolean } = {
+ text: 'Working...',
+ tokens: 0,
+ can_interrupt: true,
+ };
+
+ if (statusData.message) {
+ statusInfo.text = statusData.message;
+ } else if (statusData.status) {
+ statusInfo.text = statusData.status;
+ } else if (typeof statusData === 'string') {
+ statusInfo.text = statusData;
+ }
+
+ if (statusData.tokens) {
+ statusInfo.tokens = statusData.tokens;
+ } else if (statusData.token_count) {
+ statusInfo.tokens = statusData.token_count;
+ }
+
+ if (statusData.can_interrupt !== undefined) {
+ statusInfo.can_interrupt = statusData.can_interrupt;
+ }
+
+ setClaudeStatus(statusInfo);
+ setIsLoading(true);
+ setCanAbortSession(statusInfo.can_interrupt);
+ break;
+ }
+
+ default:
+ break;
+ }
+ }, [
+ latestMessage,
+ provider,
+ selectedProject,
+ selectedSession,
+ currentSessionId,
+ setCurrentSessionId,
+ setChatMessages,
+ setIsLoading,
+ setCanAbortSession,
+ setClaudeStatus,
+ setTokenBudget,
+ setIsSystemSessionChange,
+ setPendingPermissionRequests,
+ onSessionInactive,
+ onSessionProcessing,
+ onSessionNotProcessing,
+ onReplaceTemporarySession,
+ onNavigateToSession,
+ ]);
+}
diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts
new file mode 100644
index 0000000..c792b94
--- /dev/null
+++ b/src/components/chat/hooks/useChatSessionState.ts
@@ -0,0 +1,612 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import type { MutableRefObject } from 'react';
+import { api, authenticatedFetch } from '../../../utils/api';
+import type { ChatMessage, Provider } from '../types/types';
+import type { Project, ProjectSession } from '../../../types/app';
+import { safeLocalStorage } from '../utils/chatStorage';
+import {
+ convertCursorSessionMessages,
+ convertSessionMessages,
+ createCachedDiffCalculator,
+ type DiffCalculator,
+} from '../utils/messageTransforms';
+
+const MESSAGES_PER_PAGE = 20;
+const INITIAL_VISIBLE_MESSAGES = 100;
+
+type PendingViewSession = {
+ sessionId: string | null;
+ startedAt: number;
+};
+
+interface UseChatSessionStateArgs {
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ ws: WebSocket | null;
+ sendMessage: (message: unknown) => void;
+ autoScrollToBottom?: boolean;
+ externalMessageUpdate?: number;
+ processingSessions?: Set;
+ resetStreamingState: () => void;
+ pendingViewSessionRef: MutableRefObject;
+}
+
+interface ScrollRestoreState {
+ height: number;
+ top: number;
+}
+
+export function useChatSessionState({
+ selectedProject,
+ selectedSession,
+ ws,
+ sendMessage,
+ autoScrollToBottom,
+ externalMessageUpdate,
+ processingSessions,
+ resetStreamingState,
+ pendingViewSessionRef,
+}: UseChatSessionStateArgs) {
+ const [chatMessages, setChatMessages] = useState(() => {
+ if (typeof window !== 'undefined' && selectedProject) {
+ const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
+ if (saved) {
+ try {
+ return JSON.parse(saved) as ChatMessage[];
+ } catch {
+ console.error('Failed to parse saved chat messages, resetting');
+ safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
+ return [];
+ }
+ }
+ return [];
+ }
+ return [];
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null);
+ const [sessionMessages, setSessionMessages] = useState([]);
+ const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
+ const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
+ const [totalMessages, setTotalMessages] = useState(0);
+ const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
+ const [canAbortSession, setCanAbortSession] = useState(false);
+ const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
+ const [tokenBudget, setTokenBudget] = useState | null>(null);
+ const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
+ const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
+
+ const scrollContainerRef = useRef(null);
+ const isLoadingSessionRef = useRef(false);
+ const isLoadingMoreRef = useRef(false);
+ const topLoadLockRef = useRef(false);
+ const pendingScrollRestoreRef = useRef(null);
+ const pendingInitialScrollRef = useRef(true);
+ const messagesOffsetRef = useRef(0);
+ const scrollPositionRef = useRef({ height: 0, top: 0 });
+
+ const createDiff = useMemo(() => createCachedDiffCalculator(), []);
+
+ const loadSessionMessages = useCallback(
+ async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => {
+ if (!projectName || !sessionId) {
+ return [] as any[];
+ }
+
+ const isInitialLoad = !loadMore;
+ if (isInitialLoad) {
+ setIsLoadingSessionMessages(true);
+ } else {
+ setIsLoadingMoreMessages(true);
+ }
+
+ try {
+ const currentOffset = loadMore ? messagesOffsetRef.current : 0;
+ const response = await (api.sessionMessages as any)(
+ projectName,
+ sessionId,
+ MESSAGES_PER_PAGE,
+ currentOffset,
+ provider,
+ );
+ if (!response.ok) {
+ throw new Error('Failed to load session messages');
+ }
+
+ const data = await response.json();
+ if (isInitialLoad && data.tokenUsage) {
+ setTokenBudget(data.tokenUsage);
+ }
+
+ if (data.hasMore !== undefined) {
+ const loadedCount = data.messages?.length || 0;
+ setHasMoreMessages(Boolean(data.hasMore));
+ setTotalMessages(Number(data.total || 0));
+ messagesOffsetRef.current = currentOffset + loadedCount;
+ return data.messages || [];
+ }
+
+ const messages = data.messages || [];
+ setHasMoreMessages(false);
+ setTotalMessages(messages.length);
+ messagesOffsetRef.current = messages.length;
+ return messages;
+ } catch (error) {
+ console.error('Error loading session messages:', error);
+ return [];
+ } finally {
+ if (isInitialLoad) {
+ setIsLoadingSessionMessages(false);
+ } else {
+ setIsLoadingMoreMessages(false);
+ }
+ }
+ },
+ [],
+ );
+
+ const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => {
+ if (!projectPath || !sessionId) {
+ return [] as ChatMessage[];
+ }
+
+ setIsLoadingSessionMessages(true);
+ try {
+ const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
+ const response = await authenticatedFetch(url);
+ if (!response.ok) {
+ return [];
+ }
+
+ const data = await response.json();
+ const blobs = (data?.session?.messages || []) as any[];
+ return convertCursorSessionMessages(blobs, projectPath);
+ } catch (error) {
+ console.error('Error loading Cursor session messages:', error);
+ return [];
+ } finally {
+ setIsLoadingSessionMessages(false);
+ }
+ }, []);
+
+ const convertedMessages = useMemo(() => {
+ return convertSessionMessages(sessionMessages);
+ }, [sessionMessages]);
+
+ const scrollToBottom = useCallback(() => {
+ const container = scrollContainerRef.current;
+ if (!container) {
+ return;
+ }
+ container.scrollTop = container.scrollHeight;
+ }, []);
+
+ const isNearBottom = useCallback(() => {
+ const container = scrollContainerRef.current;
+ if (!container) {
+ return false;
+ }
+ const { scrollTop, scrollHeight, clientHeight } = container;
+ return scrollHeight - scrollTop - clientHeight < 50;
+ }, []);
+
+ const loadOlderMessages = useCallback(
+ async (container: HTMLDivElement) => {
+ 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) {
+ return false;
+ }
+
+ pendingScrollRestoreRef.current = {
+ height: previousScrollHeight,
+ top: previousScrollTop,
+ };
+ setSessionMessages((previous) => [...moreMessages, ...previous]);
+ // Keep the rendered window in sync with top-pagination so newly loaded history becomes visible.
+ setVisibleMessageCount((previousCount) => previousCount + moreMessages.length);
+ return true;
+ } finally {
+ isLoadingMoreRef.current = false;
+ }
+ },
+ [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession],
+ );
+
+ const handleScroll = useCallback(async () => {
+ const container = scrollContainerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const nearBottom = isNearBottom();
+ setIsUserScrolledUp(!nearBottom);
+
+ const scrolledNearTop = container.scrollTop < 100;
+ if (!scrolledNearTop) {
+ topLoadLockRef.current = false;
+ return;
+ }
+
+ if (topLoadLockRef.current) {
+ // After a top-load restore, release the lock once user has moved away from absolute top.
+ if (container.scrollTop > 20) {
+ topLoadLockRef.current = false;
+ }
+ return;
+ }
+
+ const didLoad = await loadOlderMessages(container);
+ if (didLoad) {
+ topLoadLockRef.current = true;
+ }
+ }, [isNearBottom, loadOlderMessages]);
+
+ 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(() => {
+ pendingInitialScrollRef.current = true;
+ topLoadLockRef.current = false;
+ pendingScrollRestoreRef.current = null;
+ setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
+ setIsUserScrolledUp(false);
+ }, [selectedProject?.name, selectedSession?.id]);
+
+ useEffect(() => {
+ if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) {
+ return;
+ }
+
+ if (chatMessages.length === 0) {
+ pendingInitialScrollRef.current = false;
+ return;
+ }
+
+ pendingInitialScrollRef.current = false;
+ setTimeout(() => {
+ scrollToBottom();
+ }, 200);
+ }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
+
+ useEffect(() => {
+ const loadMessages = async () => {
+ if (selectedSession && selectedProject) {
+ const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
+ isLoadingSessionRef.current = true;
+
+ const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
+ if (sessionChanged) {
+ if (!isSystemSessionChange) {
+ resetStreamingState();
+ pendingViewSessionRef.current = null;
+ setChatMessages([]);
+ setSessionMessages([]);
+ setClaudeStatus(null);
+ setCanAbortSession(false);
+ }
+
+ messagesOffsetRef.current = 0;
+ setHasMoreMessages(false);
+ setTotalMessages(0);
+ setTokenBudget(null);
+ setIsLoading(false);
+
+ if (ws) {
+ sendMessage({
+ type: 'check-session-status',
+ sessionId: selectedSession.id,
+ provider,
+ });
+ }
+ } else if (currentSessionId === null) {
+ messagesOffsetRef.current = 0;
+ setHasMoreMessages(false);
+ setTotalMessages(0);
+
+ if (ws) {
+ sendMessage({
+ type: 'check-session-status',
+ sessionId: selectedSession.id,
+ provider,
+ });
+ }
+ }
+
+ if (provider === 'cursor') {
+ setCurrentSessionId(selectedSession.id);
+ sessionStorage.setItem('cursorSessionId', selectedSession.id);
+
+ if (!isSystemSessionChange) {
+ const projectPath = selectedProject.fullPath || selectedProject.path || '';
+ const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
+ setSessionMessages([]);
+ setChatMessages(converted);
+ } else {
+ setIsSystemSessionChange(false);
+ }
+ } else {
+ setCurrentSessionId(selectedSession.id);
+
+ if (!isSystemSessionChange) {
+ const messages = await loadSessionMessages(
+ selectedProject.name,
+ selectedSession.id,
+ false,
+ selectedSession.__provider || 'claude',
+ );
+ setSessionMessages(messages);
+ } else {
+ setIsSystemSessionChange(false);
+ }
+ }
+ } else {
+ if (!isSystemSessionChange) {
+ resetStreamingState();
+ pendingViewSessionRef.current = null;
+ setChatMessages([]);
+ setSessionMessages([]);
+ setClaudeStatus(null);
+ setCanAbortSession(false);
+ setIsLoading(false);
+ }
+
+ setCurrentSessionId(null);
+ sessionStorage.removeItem('cursorSessionId');
+ messagesOffsetRef.current = 0;
+ setHasMoreMessages(false);
+ setTotalMessages(0);
+ setTokenBudget(null);
+ }
+
+ setTimeout(() => {
+ isLoadingSessionRef.current = false;
+ }, 250);
+ };
+
+ loadMessages();
+ }, [
+ // Intentionally exclude currentSessionId: this effect sets it and should not retrigger another full load.
+ isSystemSessionChange,
+ loadCursorSessionMessages,
+ loadSessionMessages,
+ pendingViewSessionRef,
+ resetStreamingState,
+ selectedProject,
+ selectedSession,
+ sendMessage,
+ ws,
+ ]);
+
+ useEffect(() => {
+ if (!externalMessageUpdate || !selectedSession || !selectedProject) {
+ return;
+ }
+
+ const reloadExternalMessages = async () => {
+ try {
+ const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
+
+ if (provider === 'cursor') {
+ const projectPath = selectedProject.fullPath || selectedProject.path || '';
+ const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
+ setSessionMessages([]);
+ setChatMessages(converted);
+ return;
+ }
+
+ const messages = await loadSessionMessages(
+ selectedProject.name,
+ selectedSession.id,
+ false,
+ selectedSession.__provider || 'claude',
+ );
+ setSessionMessages(messages);
+
+ const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom();
+ if (shouldAutoScroll) {
+ setTimeout(() => scrollToBottom(), 200);
+ }
+ } catch (error) {
+ console.error('Error reloading messages from external update:', error);
+ }
+ };
+
+ reloadExternalMessages();
+ }, [
+ autoScrollToBottom,
+ externalMessageUpdate,
+ isNearBottom,
+ loadCursorSessionMessages,
+ loadSessionMessages,
+ scrollToBottom,
+ selectedProject,
+ selectedSession,
+ ]);
+
+ useEffect(() => {
+ if (selectedSession?.id) {
+ pendingViewSessionRef.current = null;
+ }
+ }, [pendingViewSessionRef, selectedSession?.id]);
+
+ useEffect(() => {
+ if (sessionMessages.length > 0) {
+ setChatMessages(convertedMessages);
+ }
+ }, [convertedMessages, sessionMessages.length]);
+
+ useEffect(() => {
+ if (selectedProject && chatMessages.length > 0) {
+ safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
+ }
+ }, [chatMessages, selectedProject]);
+
+ useEffect(() => {
+ if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
+ setTokenBudget(null);
+ return;
+ }
+
+ const sessionProvider = selectedSession.__provider || 'claude';
+ if (sessionProvider !== 'claude') {
+ return;
+ }
+
+ 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();
+ }, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
+
+ const visibleMessages = useMemo(() => {
+ if (chatMessages.length <= visibleMessageCount) {
+ return chatMessages;
+ }
+ return chatMessages.slice(-visibleMessageCount);
+ }, [chatMessages, visibleMessageCount]);
+
+ useEffect(() => {
+ if (!autoScrollToBottom && scrollContainerRef.current) {
+ const container = scrollContainerRef.current;
+ scrollPositionRef.current = {
+ height: container.scrollHeight,
+ top: container.scrollTop,
+ };
+ }
+ });
+
+ useEffect(() => {
+ if (!scrollContainerRef.current || chatMessages.length === 0) {
+ return;
+ }
+
+ if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) {
+ return;
+ }
+
+ if (autoScrollToBottom) {
+ if (!isUserScrolledUp) {
+ setTimeout(() => scrollToBottom(), 50);
+ }
+ return;
+ }
+
+ const container = scrollContainerRef.current;
+ const prevHeight = scrollPositionRef.current.height;
+ const prevTop = scrollPositionRef.current.top;
+ const newHeight = container.scrollHeight;
+ const heightDiff = newHeight - prevHeight;
+
+ if (heightDiff > 0 && prevTop > 0) {
+ container.scrollTop = prevTop + heightDiff;
+ }
+ }, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
+
+ useEffect(() => {
+ const container = scrollContainerRef.current;
+ if (!container) {
+ return;
+ }
+
+ container.addEventListener('scroll', handleScroll);
+ return () => container.removeEventListener('scroll', handleScroll);
+ }, [handleScroll]);
+
+ useEffect(() => {
+ const activeViewSessionId = selectedSession?.id || currentSessionId;
+ if (!activeViewSessionId || !processingSessions) {
+ return;
+ }
+
+ const shouldBeProcessing = processingSessions.has(activeViewSessionId);
+ if (shouldBeProcessing && !isLoading) {
+ setIsLoading(true);
+ setCanAbortSession(true);
+ }
+ }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
+
+ const loadEarlierMessages = useCallback(() => {
+ setVisibleMessageCount((previousCount) => previousCount + 100);
+ }, []);
+
+ return {
+ chatMessages,
+ setChatMessages,
+ isLoading,
+ setIsLoading,
+ currentSessionId,
+ setCurrentSessionId,
+ sessionMessages,
+ setSessionMessages,
+ isLoadingSessionMessages,
+ isLoadingMoreMessages,
+ hasMoreMessages,
+ totalMessages,
+ isSystemSessionChange,
+ setIsSystemSessionChange,
+ canAbortSession,
+ setCanAbortSession,
+ isUserScrolledUp,
+ setIsUserScrolledUp,
+ tokenBudget,
+ setTokenBudget,
+ visibleMessageCount,
+ visibleMessages,
+ loadEarlierMessages,
+ claudeStatus,
+ setClaudeStatus,
+ createDiff,
+ scrollContainerRef,
+ scrollToBottom,
+ isNearBottom,
+ handleScroll,
+ loadSessionMessages,
+ loadCursorSessionMessages,
+ };
+}
diff --git a/src/components/chat/hooks/useFileMentions.tsx b/src/components/chat/hooks/useFileMentions.tsx
new file mode 100644
index 0000000..4445086
--- /dev/null
+++ b/src/components/chat/hooks/useFileMentions.tsx
@@ -0,0 +1,268 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
+import { api } from '../../../utils/api';
+import { escapeRegExp } from '../utils/chatFormatting';
+import type { Project } from '../../../types/app';
+
+interface ProjectFileNode {
+ name: string;
+ type: 'file' | 'directory';
+ path?: string;
+ children?: ProjectFileNode[];
+}
+
+export interface MentionableFile {
+ name: string;
+ path: string;
+ relativePath?: string;
+}
+
+interface UseFileMentionsOptions {
+ selectedProject: Project | null;
+ input: string;
+ setInput: Dispatch>;
+ textareaRef: RefObject;
+}
+
+const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFile[] => {
+ let flattened: MentionableFile[] = [];
+
+ files.forEach((file) => {
+ const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
+ if (file.type === 'directory' && file.children) {
+ flattened = flattened.concat(flattenFileTree(file.children, fullPath));
+ return;
+ }
+
+ if (file.type === 'file') {
+ flattened.push({
+ name: file.name,
+ path: fullPath,
+ relativePath: file.path,
+ });
+ }
+ });
+
+ return flattened;
+};
+
+export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) {
+ const [fileList, setFileList] = useState([]);
+ const [fileMentions, setFileMentions] = useState([]);
+ const [filteredFiles, setFilteredFiles] = useState([]);
+ const [showFileDropdown, setShowFileDropdown] = useState(false);
+ const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
+ const [cursorPosition, setCursorPosition] = useState(0);
+ const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
+
+ useEffect(() => {
+ const abortController = new AbortController();
+
+ const fetchProjectFiles = async () => {
+ const projectName = selectedProject?.name;
+ setFileList([]);
+ setFilteredFiles([]);
+ if (!projectName) {
+ return;
+ }
+
+
+ try {
+ const response = await api.getFiles(projectName, { signal: abortController.signal });
+ if (!response.ok) {
+ return;
+ }
+
+ const files = (await response.json()) as ProjectFileNode[];
+ setFileList(flattenFileTree(files));
+ } catch (error) {
+ // Ignore aborts from rapid project switches; we only care about the latest request.
+ if ((error as { name?: string })?.name === 'AbortError') {
+ return;
+ }
+ console.error('Error fetching files:', error);
+ }
+ };
+
+ fetchProjectFiles();
+ return () => {
+ abortController.abort();
+ };
+ }, [selectedProject?.name]);
+
+ useEffect(() => {
+ const textBeforeCursor = input.slice(0, cursorPosition);
+ const lastAtIndex = textBeforeCursor.lastIndexOf('@');
+
+ if (lastAtIndex === -1) {
+ setShowFileDropdown(false);
+ setAtSymbolPosition(-1);
+ return;
+ }
+
+ const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
+ if (textAfterAt.includes(' ')) {
+ setShowFileDropdown(false);
+ setAtSymbolPosition(-1);
+ return;
+ }
+
+ setAtSymbolPosition(lastAtIndex);
+ setShowFileDropdown(true);
+ setSelectedFileIndex(-1);
+
+ const matchingFiles = fileList
+ .filter(
+ (file) =>
+ file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
+ file.path.toLowerCase().includes(textAfterAt.toLowerCase()),
+ )
+ .slice(0, 10);
+
+ setFilteredFiles(matchingFiles);
+ }, [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 uniqueMentions = Array.from(new Set(activeFileMentions));
+ return uniqueMentions.sort((mentionA, mentionB) => mentionB.length - mentionA.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: string) => {
+ if (!text) {
+ return '';
+ }
+ if (!fileMentionRegex) {
+ return text;
+ }
+
+ const parts = text.split(fileMentionRegex);
+ return parts.map((part, index) =>
+ fileMentionSet.has(part) ? (
+
+ {part}
+
+ ) : (
+ {part}
+ ),
+ );
+ },
+ [fileMentionRegex, fileMentionSet],
+ );
+
+ const selectFile = useCallback(
+ (file: MentionableFile) => {
+ 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 newCursorPosition = textBeforeAt.length + file.path.length + 1;
+
+ if (textareaRef.current && !textareaRef.current.matches(':focus')) {
+ textareaRef.current.focus();
+ }
+
+ setInput(newInput);
+ setCursorPosition(newCursorPosition);
+ setFileMentions((previousMentions) =>
+ previousMentions.includes(file.path) ? previousMentions : [...previousMentions, file.path],
+ );
+
+ setShowFileDropdown(false);
+ setAtSymbolPosition(-1);
+
+ if (!textareaRef.current) {
+ return;
+ }
+
+ requestAnimationFrame(() => {
+ if (!textareaRef.current) {
+ return;
+ }
+ textareaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
+ if (!textareaRef.current.matches(':focus')) {
+ textareaRef.current.focus();
+ }
+ });
+ },
+ [input, atSymbolPosition, textareaRef, setInput],
+ );
+
+ const handleFileMentionsKeyDown = useCallback(
+ (event: KeyboardEvent): boolean => {
+ if (!showFileDropdown || filteredFiles.length === 0) {
+ return false;
+ }
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ setSelectedFileIndex((previousIndex) =>
+ previousIndex < filteredFiles.length - 1 ? previousIndex + 1 : 0,
+ );
+ return true;
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ setSelectedFileIndex((previousIndex) =>
+ previousIndex > 0 ? previousIndex - 1 : filteredFiles.length - 1,
+ );
+ return true;
+ }
+
+ if (event.key === 'Tab' || event.key === 'Enter') {
+ event.preventDefault();
+ if (selectedFileIndex >= 0) {
+ selectFile(filteredFiles[selectedFileIndex]);
+ } else if (filteredFiles.length > 0) {
+ selectFile(filteredFiles[0]);
+ }
+ return true;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setShowFileDropdown(false);
+ return true;
+ }
+
+ return false;
+ },
+ [showFileDropdown, filteredFiles, selectedFileIndex, selectFile],
+ );
+
+ return {
+ showFileDropdown,
+ filteredFiles,
+ selectedFileIndex,
+ renderInputWithMentions,
+ selectFile,
+ setCursorPosition,
+ handleFileMentionsKeyDown,
+ };
+}
diff --git a/src/components/chat/hooks/useSlashCommands.ts b/src/components/chat/hooks/useSlashCommands.ts
new file mode 100644
index 0000000..f14fc43
--- /dev/null
+++ b/src/components/chat/hooks/useSlashCommands.ts
@@ -0,0 +1,375 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
+import Fuse from 'fuse.js';
+import { authenticatedFetch } from '../../../utils/api';
+import { safeLocalStorage } from '../utils/chatStorage';
+import type { Project } from '../../../types/app';
+
+const COMMAND_QUERY_DEBOUNCE_MS = 150;
+
+export interface SlashCommand {
+ name: string;
+ description?: string;
+ namespace?: string;
+ path?: string;
+ type?: string;
+ metadata?: Record;
+ [key: string]: unknown;
+}
+
+interface UseSlashCommandsOptions {
+ selectedProject: Project | null;
+ input: string;
+ setInput: Dispatch>;
+ textareaRef: RefObject;
+ onExecuteCommand: (command: SlashCommand) => void | Promise;
+}
+
+const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
+
+const readCommandHistory = (projectName: string): Record => {
+ const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));
+ if (!history) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(history);
+ } catch (error) {
+ console.error('Error parsing command history:', error);
+ return {};
+ }
+};
+
+const saveCommandHistory = (projectName: string, history: Record) => {
+ safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));
+};
+
+const isPromiseLike = (value: unknown): value is Promise =>
+ Boolean(value) && typeof (value as Promise).then === 'function';
+
+export function useSlashCommands({
+ selectedProject,
+ input,
+ setInput,
+ textareaRef,
+ onExecuteCommand,
+}: UseSlashCommandsOptions) {
+ const [slashCommands, setSlashCommands] = useState([]);
+ const [filteredCommands, setFilteredCommands] = useState([]);
+ const [showCommandMenu, setShowCommandMenu] = useState(false);
+ const [commandQuery, setCommandQuery] = useState('');
+ const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
+ const [slashPosition, setSlashPosition] = useState(-1);
+
+ const commandQueryTimerRef = useRef(null);
+
+ const clearCommandQueryTimer = useCallback(() => {
+ if (commandQueryTimerRef.current !== null) {
+ window.clearTimeout(commandQueryTimerRef.current);
+ commandQueryTimerRef.current = null;
+ }
+ }, []);
+
+ const resetCommandMenuState = useCallback(() => {
+ setShowCommandMenu(false);
+ setSlashPosition(-1);
+ setCommandQuery('');
+ setSelectedCommandIndex(-1);
+ clearCommandQueryTimer();
+ }, [clearCommandQueryTimer]);
+
+ useEffect(() => {
+ const fetchCommands = async () => {
+ if (!selectedProject) {
+ setSlashCommands([]);
+ setFilteredCommands([]);
+ 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();
+ const allCommands: SlashCommand[] = [
+ ...((data.builtIn || []) as SlashCommand[]).map((command) => ({
+ ...command,
+ type: 'built-in',
+ })),
+ ...((data.custom || []) as SlashCommand[]).map((command) => ({
+ ...command,
+ type: 'custom',
+ })),
+ ];
+
+ const parsedHistory = readCommandHistory(selectedProject.name);
+ const sortedCommands = [...allCommands].sort((commandA, commandB) => {
+ const commandAUsage = parsedHistory[commandA.name] || 0;
+ const commandBUsage = parsedHistory[commandB.name] || 0;
+ return commandBUsage - commandAUsage;
+ });
+
+ setSlashCommands(sortedCommands);
+ } catch (error) {
+ console.error('Error fetching slash commands:', error);
+ setSlashCommands([]);
+ }
+ };
+
+ fetchCommands();
+ }, [selectedProject]);
+
+ useEffect(() => {
+ if (!showCommandMenu) {
+ setSelectedCommandIndex(-1);
+ }
+ }, [showCommandMenu]);
+
+ 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]);
+
+ useEffect(() => {
+ if (!commandQuery) {
+ setFilteredCommands(slashCommands);
+ return;
+ }
+
+ if (!fuse) {
+ setFilteredCommands([]);
+ return;
+ }
+
+ const results = fuse.search(commandQuery);
+ setFilteredCommands(results.map((result) => result.item));
+ }, [commandQuery, slashCommands, fuse]);
+
+ const frequentCommands = useMemo(() => {
+ if (!selectedProject || slashCommands.length === 0) {
+ return [];
+ }
+
+ const parsedHistory = readCommandHistory(selectedProject.name);
+
+ return slashCommands
+ .map((command) => ({
+ ...command,
+ usageCount: parsedHistory[command.name] || 0,
+ }))
+ .filter((command) => command.usageCount > 0)
+ .sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)
+ .slice(0, 5);
+ }, [selectedProject, slashCommands]);
+
+ const trackCommandUsage = useCallback(
+ (command: SlashCommand) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ const parsedHistory = readCommandHistory(selectedProject.name);
+ parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
+ saveCommandHistory(selectedProject.name, parsedHistory);
+ },
+ [selectedProject],
+ );
+
+ const selectCommandFromKeyboard = useCallback(
+ (command: SlashCommand) => {
+ 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}`;
+
+ setInput(newInput);
+ resetCommandMenuState();
+
+ const executionResult = onExecuteCommand(command);
+ if (isPromiseLike(executionResult)) {
+ executionResult.catch(() => {
+ // Keep behavior silent; execution errors are handled by caller.
+ });
+ }
+ },
+ [input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
+ );
+
+ const handleCommandSelect = useCallback(
+ (command: SlashCommand | null, index: number, isHover: boolean) => {
+ if (!command || !selectedProject) {
+ return;
+ }
+
+ if (isHover) {
+ setSelectedCommandIndex(index);
+ return;
+ }
+
+ trackCommandUsage(command);
+ const executionResult = onExecuteCommand(command);
+
+ if (isPromiseLike(executionResult)) {
+ executionResult.then(() => {
+ resetCommandMenuState();
+ });
+ executionResult.catch(() => {
+ // Keep behavior silent; execution errors are handled by caller.
+ });
+ } else {
+ resetCommandMenuState();
+ }
+ },
+ [selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
+ );
+
+ const handleToggleCommandMenu = useCallback(() => {
+ const isOpening = !showCommandMenu;
+ setShowCommandMenu(isOpening);
+ setCommandQuery('');
+ setSelectedCommandIndex(-1);
+
+ if (isOpening) {
+ setFilteredCommands(slashCommands);
+ }
+
+ textareaRef.current?.focus();
+ }, [showCommandMenu, slashCommands, textareaRef]);
+
+ const handleCommandInputChange = useCallback(
+ (newValue: string, cursorPos: number) => {
+ if (!newValue.trim()) {
+ resetCommandMenuState();
+ return;
+ }
+
+ const textBeforeCursor = newValue.slice(0, cursorPos);
+ const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
+ const inCodeBlock = backticksBefore % 2 === 1;
+
+ if (inCodeBlock) {
+ resetCommandMenuState();
+ return;
+ }
+
+ const slashPattern = /(^|\s)\/(\S*)$/;
+ const match = textBeforeCursor.match(slashPattern);
+
+ if (!match) {
+ resetCommandMenuState();
+ return;
+ }
+
+ const slashPos = (match.index || 0) + match[1].length;
+ const query = match[2];
+
+ setSlashPosition(slashPos);
+ setShowCommandMenu(true);
+ setSelectedCommandIndex(-1);
+
+ clearCommandQueryTimer();
+ commandQueryTimerRef.current = window.setTimeout(() => {
+ setCommandQuery(query);
+ }, COMMAND_QUERY_DEBOUNCE_MS);
+ },
+ [resetCommandMenuState, clearCommandQueryTimer],
+ );
+
+ const handleCommandMenuKeyDown = useCallback(
+ (event: KeyboardEvent): boolean => {
+ if (!showCommandMenu) {
+ return false;
+ }
+
+ if (!filteredCommands.length) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ resetCommandMenuState();
+ return true;
+ }
+ return false;
+ }
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ setSelectedCommandIndex((previousIndex) =>
+ previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
+ );
+ return true;
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ setSelectedCommandIndex((previousIndex) =>
+ previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
+ );
+ return true;
+ }
+
+ if (event.key === 'Tab' || event.key === 'Enter') {
+ event.preventDefault();
+ if (selectedCommandIndex >= 0) {
+ selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
+ } else if (filteredCommands.length > 0) {
+ selectCommandFromKeyboard(filteredCommands[0]);
+ }
+ return true;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ resetCommandMenuState();
+ return true;
+ }
+
+ return false;
+ },
+ [showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],
+ );
+
+ useEffect(
+ () => () => {
+ clearCommandQueryTimer();
+ },
+ [clearCommandQueryTimer],
+ );
+
+ return {
+ slashCommands,
+ slashCommandsCount: slashCommands.length,
+ filteredCommands,
+ frequentCommands,
+ commandQuery,
+ showCommandMenu,
+ selectedCommandIndex,
+ resetCommandMenuState,
+ handleCommandSelect,
+ handleToggleCommandMenu,
+ handleCommandInputChange,
+ handleCommandMenuKeyDown,
+ };
+}
diff --git a/src/components/chat/tools/README.md b/src/components/chat/tools/README.md
new file mode 100644
index 0000000..206d90f
--- /dev/null
+++ b/src/components/chat/tools/README.md
@@ -0,0 +1,224 @@
+# Tool Rendering System
+
+## Overview
+
+Config-driven architecture for rendering tool executions in chat. All tool display behavior is defined in `toolConfigs.ts` — no scattered conditionals. Two base display patterns: **OneLineDisplay** for compact tools, **CollapsibleDisplay** for tools with expandable content.
+
+Non-error tool results route through `ToolRenderer` with `mode="result"` (single source of truth). Error results are handled inline in `MessageComponent` with a red error box.
+
+---
+
+## Architecture
+
+```
+tools/
+├── components/
+│ ├── OneLineDisplay.tsx # Compact one-line tool display
+│ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern)
+│ ├── CollapsibleSection.tsx # / wrapper
+│ ├── ContentRenderers/
+│ │ ├── DiffViewer.tsx # File diff viewer (memoized)
+│ │ ├── MarkdownContent.tsx # Markdown renderer
+│ │ ├── FileListContent.tsx # Comma-separated clickable file list
+│ │ ├── TodoListContent.tsx # Todo items with status badges
+│ │ ├── TaskListContent.tsx # Task tracker with progress bar
+│ │ └── TextContent.tsx # Plain text / JSON / code
+├── configs/
+│ └── toolConfigs.ts # All tool configs + ToolDisplayConfig type
+├── ToolRenderer.tsx # Main router (React.memo wrapped)
+└── README.md
+```
+
+---
+
+## Display Patterns
+
+### OneLineDisplay
+
+Used by: Bash, Read, Grep, Glob, TodoRead, TaskCreate, TaskUpdate, TaskGet
+
+Renders as a single line with `border-l-2` accent. Supports multiple rendering modes based on `action`:
+
+- **terminal** (`style: 'terminal'`) — Dark pill around command text, green `$` prompt
+- **open-file** — Shows filename only (truncated from full path), clickable to open
+- **jump-to-results** — Shows pattern with anchor link to result section
+- **copy** — Shows value with hover copy button
+- **none** — Plain display
+
+```tsx
+ ...} // Click handler
+ colorScheme={{ // Per-tool colors
+ primary: 'text-...',
+ border: 'border-...',
+ icon: 'text-...'
+ }}
+ resultId="tool-result-x" // For jump-to-results anchor
+ toolResult={...} // For conditional jump arrow
+ toolId="x" // Tool use ID
+/>
+```
+
+### CollapsibleDisplay
+
+Used by: Edit, Write, ApplyPatch, Grep/Glob results, TodoWrite, TaskList/TaskGet results, ExitPlanMode, Default
+
+Wraps `CollapsibleSection` (``/``) with a `border-l-2` accent colored by tool category. Accepts **children** directly (not contentProps).
+
+```tsx
+ ...} // Makes title a clickable link (for edit tools)
+ showRawParameters={true} // Show raw JSON toggle
+ rawContent="..." // Raw JSON string
+ toolCategory="edit" // Drives border color
+>
+ // Content as children
+
+```
+
+**Tool category colors** (via `border-l-2`):
+| Category | Tools | Color |
+|----------|-------|-------|
+| `edit` | Edit, Write, ApplyPatch | amber |
+| `bash` | Bash | green |
+| `search` | Grep, Glob | gray |
+| `todo` | TodoWrite, TodoRead | violet |
+| `task` | TaskCreate/Update/List/Get | violet |
+| `plan` | ExitPlanMode | indigo |
+| `default` | everything else | neutral gray |
+
+---
+
+## Content Renderers
+
+Specialized components for different content types, rendered as children of `CollapsibleDisplay`:
+
+| contentType | Component | Used by |
+|---|---|---|
+| `diff` | `DiffViewer` | Edit, Write, ApplyPatch |
+| `markdown` | `MarkdownContent` | ExitPlanMode |
+| `file-list` | `FileListContent` | Grep/Glob results |
+| `todo-list` | `TodoListContent` | TodoWrite, TodoRead |
+| `task` | `TaskListContent` | TaskList, TaskGet results |
+| `text` | `TextContent` | Default fallback |
+| `success-message` | inline SVG | TodoWrite result |
+
+---
+
+## Adding a New Tool
+
+**Step 1:** Add config to `configs/toolConfigs.ts`
+
+```typescript
+MyTool: {
+ input: {
+ type: 'one-line', // or 'collapsible'
+ label: 'MyTool',
+ getValue: (input) => input.some_field,
+ action: 'open-file',
+ colorScheme: {
+ primary: 'text-purple-600 dark:text-purple-400',
+ border: 'border-purple-400 dark:border-purple-500'
+ }
+ },
+ result: {
+ hideOnSuccess: true // Only show errors
+ }
+}
+```
+
+**Step 2:** If the tool needs a category color, add it to `getToolCategory()` in `ToolRenderer.tsx`.
+
+**That's it.** The ToolRenderer auto-routes based on config.
+
+---
+
+## Configuration Reference
+
+### ToolDisplayConfig
+
+```typescript
+interface ToolDisplayConfig {
+ input: {
+ type: 'one-line' | 'collapsible' | 'hidden';
+
+ // One-line
+ icon?: string;
+ label?: string;
+ getValue?: (input) => string;
+ getSecondary?: (input) => string | undefined;
+ action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
+ style?: string; // 'terminal' for Bash
+ wrapText?: boolean;
+ colorScheme?: {
+ primary?: string;
+ secondary?: string;
+ background?: string;
+ border?: string;
+ icon?: string;
+ };
+
+ // Collapsible
+ title?: string | ((input) => string);
+ defaultOpen?: boolean;
+ contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
+ getContentProps?: (input, helpers?) => any;
+ actionButton?: 'none';
+ };
+
+ result?: {
+ hidden?: boolean; // Never show
+ hideOnSuccess?: boolean; // Only show errors
+ type?: 'one-line' | 'collapsible' | 'special';
+ title?: string | ((result) => string);
+ defaultOpen?: boolean;
+ contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
+ getMessage?: (result) => string;
+ getContentProps?: (result) => any;
+ };
+}
+```
+
+---
+
+## All Configured Tools
+
+| Tool | Input | Result | Notes |
+|------|-------|--------|-------|
+| Bash | terminal one-line | hide success | Dark command pill, green accent |
+| Read | one-line (open-file) | hidden | Shows filename, clicks to open |
+| Edit | collapsible (diff) | hide success | Amber border, clickable filename |
+| Write | collapsible (diff) | hide success | "New" badge on diff |
+| ApplyPatch | collapsible (diff) | hide success | "Patch" badge on diff |
+| Grep | one-line (jump) | collapsible file-list | Collapsed by default |
+| Glob | one-line (jump) | collapsible file-list | Collapsed by default |
+| TodoWrite | collapsible (todo-list) | success message | |
+| TodoRead | one-line | collapsible todo-list | |
+| TaskCreate | one-line | hide success | Shows task subject |
+| TaskUpdate | one-line | hide success | Shows `#id → status` |
+| TaskList | one-line | collapsible task | Progress bar, status icons |
+| TaskGet | one-line | collapsible task | Task details with status |
+| ExitPlanMode | collapsible (markdown) | collapsible markdown | Also registered as `exit_plan_mode` |
+| Default | collapsible (code) | collapsible text | Fallback for unknown tools |
+
+---
+
+## Performance
+
+- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed
+- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes
+- **DiffViewer** memoizes `createDiff()` — expensive diff computation cached
+- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`
+- Tool results route through `ToolRenderer` (no duplicate rendering paths)
+- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)
+- Configs are static module-level objects — zero runtime overhead for lookups
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx
new file mode 100644
index 0000000..029957d
--- /dev/null
+++ b/src/components/chat/tools/ToolRenderer.tsx
@@ -0,0 +1,209 @@
+import React, { memo, useMemo, useCallback } from 'react';
+import { getToolConfig } from './configs/toolConfigs';
+import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components';
+import type { Project } from '../../../types/app';
+
+type DiffLine = {
+ type: string;
+ content: string;
+ lineNum: number;
+};
+
+interface ToolRendererProps {
+ toolName: string;
+ toolInput: any;
+ toolResult?: any;
+ toolId?: string;
+ mode: 'input' | 'result';
+ onFileOpen?: (filePath: string, diffInfo?: any) => void;
+ createDiff?: (oldStr: string, newStr: string) => DiffLine[];
+ selectedProject?: Project | null;
+ autoExpandTools?: boolean;
+ showRawParameters?: boolean;
+ rawToolInput?: string;
+}
+
+function getToolCategory(toolName: string): string {
+ if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';
+ if (['Grep', 'Glob'].includes(toolName)) return 'search';
+ if (toolName === 'Bash') return 'bash';
+ if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
+ if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
+ if (toolName === 'Task') return 'agent'; // Subagent task
+ if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
+ return 'default';
+}
+
+/**
+ * Main tool renderer router
+ * Routes to OneLineDisplay or CollapsibleDisplay based on tool config
+ */
+export const ToolRenderer: React.FC = memo(({
+ toolName,
+ toolInput,
+ toolResult,
+ toolId,
+ mode,
+ onFileOpen,
+ createDiff,
+ selectedProject,
+ autoExpandTools = false,
+ showRawParameters = false,
+ rawToolInput
+}) => {
+ const config = getToolConfig(toolName);
+ const displayConfig: any = mode === 'input' ? config.input : config.result;
+
+ const parsedData = useMemo(() => {
+ try {
+ const rawData = mode === 'input' ? toolInput : toolResult;
+ return typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+ } catch {
+ return mode === 'input' ? toolInput : toolResult;
+ }
+ }, [mode, toolInput, toolResult]);
+
+ const handleAction = useCallback(() => {
+ if (displayConfig?.action === 'open-file' && onFileOpen) {
+ const value = displayConfig.getValue?.(parsedData) || '';
+ onFileOpen(value);
+ }
+ }, [displayConfig, parsedData, onFileOpen]);
+
+ // Keep hooks above this guard so hook call order stays stable across renders.
+ if (!displayConfig) return null;
+
+ if (displayConfig.type === 'one-line') {
+ const value = displayConfig.getValue?.(parsedData) || '';
+ const secondary = displayConfig.getSecondary?.(parsedData);
+
+ return (
+
+ );
+ }
+
+ if (displayConfig.type === 'collapsible') {
+ const title = typeof displayConfig.title === 'function'
+ ? displayConfig.title(parsedData)
+ : displayConfig.title || 'Details';
+
+ const defaultOpen = displayConfig.defaultOpen !== undefined
+ ? displayConfig.defaultOpen
+ : autoExpandTools;
+
+ const contentProps = displayConfig.getContentProps?.(parsedData, {
+ selectedProject,
+ createDiff,
+ onFileOpen
+ }) || {};
+
+ // Build the content component based on contentType
+ let contentComponent: React.ReactNode = null;
+
+ switch (displayConfig.contentType) {
+ case 'diff':
+ if (createDiff) {
+ contentComponent = (
+ onFileOpen?.(contentProps.filePath)}
+ />
+ );
+ }
+ break;
+
+ case 'markdown':
+ contentComponent = ;
+ break;
+
+ case 'file-list':
+ contentComponent = (
+
+ );
+ break;
+
+ case 'todo-list':
+ if (contentProps.todos?.length > 0) {
+ contentComponent = (
+
+ );
+ }
+ break;
+
+ case 'task':
+ contentComponent = ;
+ break;
+
+ case 'text':
+ contentComponent = (
+
+ );
+ break;
+
+ case 'success-message': {
+ const msg = displayConfig.getMessage?.(parsedData) || 'Success';
+ contentComponent = (
+
+ );
+ break;
+ }
+ }
+
+ // For edit tools, make the title (filename) clickable to open the file
+ const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
+ ? () => onFileOpen(contentProps.filePath, {
+ old_string: contentProps.oldContent,
+ new_string: contentProps.newContent
+ })
+ : undefined;
+
+ return (
+
+ {contentComponent}
+
+ );
+ }
+
+ return null;
+});
+
+ToolRenderer.displayName = 'ToolRenderer';
diff --git a/src/components/chat/tools/components/CollapsibleDisplay.tsx b/src/components/chat/tools/components/CollapsibleDisplay.tsx
new file mode 100644
index 0000000..f429cf5
--- /dev/null
+++ b/src/components/chat/tools/components/CollapsibleDisplay.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { CollapsibleSection } from './CollapsibleSection';
+
+interface CollapsibleDisplayProps {
+ toolName: string;
+ toolId?: string;
+ title: string;
+ defaultOpen?: boolean;
+ action?: React.ReactNode;
+ onTitleClick?: () => void;
+ children: React.ReactNode;
+ showRawParameters?: boolean;
+ rawContent?: string;
+ className?: string;
+ toolCategory?: string;
+}
+
+const borderColorMap: Record = {
+ edit: 'border-l-amber-500 dark:border-l-amber-400',
+ search: 'border-l-gray-400 dark:border-l-gray-500',
+ bash: 'border-l-green-500 dark:border-l-green-400',
+ todo: 'border-l-violet-500 dark:border-l-violet-400',
+ task: 'border-l-violet-500 dark:border-l-violet-400',
+ agent: 'border-l-purple-500 dark:border-l-purple-400',
+ plan: 'border-l-indigo-500 dark:border-l-indigo-400',
+ default: 'border-l-gray-300 dark:border-l-gray-600',
+};
+
+export const CollapsibleDisplay: React.FC = ({
+ toolName,
+ title,
+ defaultOpen = false,
+ action,
+ onTitleClick,
+ children,
+ showRawParameters = false,
+ rawContent,
+ className = '',
+ toolCategory
+}) => {
+ // Fall back to default styling for unknown/new categories so className never includes "undefined".
+ const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
+
+ return (
+
+
+ {children}
+
+ {showRawParameters && rawContent && (
+
+
+
+
+
+ raw params
+
+
+ {rawContent}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/CollapsibleSection.tsx b/src/components/chat/tools/components/CollapsibleSection.tsx
new file mode 100644
index 0000000..f83135c
--- /dev/null
+++ b/src/components/chat/tools/components/CollapsibleSection.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+interface CollapsibleSectionProps {
+ title: string;
+ toolName?: string;
+ open?: boolean;
+ action?: React.ReactNode;
+ onTitleClick?: () => void;
+ children: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Reusable collapsible section with consistent styling
+ */
+export const CollapsibleSection: React.FC = ({
+ title,
+ toolName,
+ open = false,
+ action,
+ onTitleClick,
+ children,
+ className = ''
+}) => {
+ return (
+
+
+
+
+
+ {toolName && (
+ {toolName}
+ )}
+ {toolName && (
+ /
+ )}
+ {onTitleClick ? (
+ { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
+ className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors"
+ >
+ {title}
+
+ ) : (
+
+ {title}
+
+ )}
+ {action && {action} }
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx b/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx
new file mode 100644
index 0000000..695b4f2
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/FileListContent.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+interface FileListItem {
+ path: string;
+ onClick?: () => void;
+}
+
+interface FileListContentProps {
+ files: string[] | FileListItem[];
+ onFileClick?: (filePath: string) => void;
+ title?: string;
+}
+
+/**
+ * Renders a compact comma-separated list of clickable file names
+ * Used by: Grep/Glob results
+ */
+export const FileListContent: React.FC = ({
+ files,
+ onFileClick,
+ title
+}) => {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {files.map((file, index) => {
+ const filePath = typeof file === 'string' ? file : file.path;
+ const fileName = filePath.split('/').pop() || filePath;
+ const handleClick = typeof file === 'string'
+ ? () => onFileClick?.(file)
+ : file.onClick;
+
+ return (
+
+
+ {fileName}
+
+ {index < files.length - 1 && (
+ ,
+ )}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx b/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx
new file mode 100644
index 0000000..28baf22
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Markdown } from '../../../view/subcomponents/Markdown';
+
+interface MarkdownContentProps {
+ content: string;
+ className?: string;
+}
+
+/**
+ * Renders markdown content with proper styling
+ * Used by: exit_plan_mode, long text results, etc.
+ */
+export const MarkdownContent: React.FC = ({
+ content,
+ className = 'mt-1 prose prose-sm max-w-none dark:prose-invert'
+}) => {
+ return (
+
+ {content}
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/TaskListContent.tsx b/src/components/chat/tools/components/ContentRenderers/TaskListContent.tsx
new file mode 100644
index 0000000..5ae3f71
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/TaskListContent.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+
+interface TaskItem {
+ id: string;
+ subject: string;
+ status: 'pending' | 'in_progress' | 'completed';
+ owner?: string;
+ blockedBy?: string[];
+}
+
+interface TaskListContentProps {
+ content: string;
+}
+
+function parseTaskContent(content: string): TaskItem[] {
+ const tasks: TaskItem[] = [];
+ const lines = content.split('\n');
+
+ for (const line of lines) {
+ // Match patterns like: #15. [in_progress] Subject here
+ // or: - #15 [in_progress] Subject (owner: agent)
+ // or: #15. Subject here (status: in_progress)
+ const match = line.match(/#(\d+)\.?\s*(?:\[(\w+)\]\s*)?(.+?)(?:\s*\((?:owner:\s*\w+)?\))?$/);
+ if (match) {
+ const [, id, status, subject] = match;
+ const blockedMatch = line.match(/blockedBy:\s*\[([^\]]*)\]/);
+ tasks.push({
+ id,
+ subject: subject.trim(),
+ status: (status as TaskItem['status']) || 'pending',
+ blockedBy: blockedMatch ? blockedMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined
+ });
+ }
+ }
+
+ return tasks;
+}
+
+const statusConfig = {
+ completed: {
+ icon: (
+
+
+
+ ),
+ textClass: 'line-through text-gray-400 dark:text-gray-500',
+ badgeClass: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
+ },
+ in_progress: {
+ icon: (
+
+
+
+ ),
+ textClass: 'text-gray-900 dark:text-gray-100',
+ badgeClass: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
+ },
+ pending: {
+ icon: (
+
+
+
+ ),
+ textClass: 'text-gray-700 dark:text-gray-300',
+ badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'
+ }
+};
+
+/**
+ * Renders task list results with proper status icons and compact layout
+ * Parses text content from TaskList/TaskGet results
+ */
+export const TaskListContent: React.FC = ({ content }) => {
+ const tasks = parseTaskContent(content);
+
+ // If we couldn't parse any tasks, fall back to text display
+ if (tasks.length === 0) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ const completed = tasks.filter(t => t.status === 'completed').length;
+ const total = tasks.length;
+
+ return (
+
+
+
+ {completed}/{total} completed
+
+
+
0 ? (completed / total) * 100 : 0}%` }}
+ />
+
+
+
+ {tasks.map((task) => {
+ const config = statusConfig[task.status] || statusConfig.pending;
+ return (
+
+ {config.icon}
+
+ #{task.id}
+
+
+ {task.subject}
+
+
+ {task.status.replace('_', ' ')}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/TextContent.tsx b/src/components/chat/tools/components/ContentRenderers/TextContent.tsx
new file mode 100644
index 0000000..811165a
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/TextContent.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+interface TextContentProps {
+ content: string;
+ format?: 'plain' | 'json' | 'code';
+ className?: string;
+}
+
+/**
+ * Renders plain text, JSON, or code content
+ * Used by: Raw parameters, generic text results, JSON responses
+ */
+export const TextContent: React.FC
= ({
+ content,
+ format = 'plain',
+ className = ''
+}) => {
+ if (format === 'json') {
+ let formattedJson = content;
+ try {
+ const parsed = JSON.parse(content);
+ formattedJson = JSON.stringify(parsed, null, 2);
+ } catch (e) {
+ // If parsing fails, use original content
+ }
+
+ return (
+
+ {formattedJson}
+
+ );
+ }
+
+ if (format === 'code') {
+ return (
+
+ {content}
+
+ );
+ }
+
+ // Plain text
+ return (
+
+ {content}
+
+ );
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx
new file mode 100644
index 0000000..5d318ba
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import TodoList from '../../../../TodoList';
+
+interface TodoListContentProps {
+ todos: Array<{
+ id?: string;
+ content: string;
+ status: string;
+ priority?: string;
+ }>;
+ isResult?: boolean;
+}
+
+/**
+ * Renders a todo list
+ * Used by: TodoWrite, TodoRead
+ */
+export const TodoListContent: React.FC = ({
+ todos,
+ isResult = false
+}) => {
+ return ;
+};
diff --git a/src/components/chat/tools/components/ContentRenderers/index.ts b/src/components/chat/tools/components/ContentRenderers/index.ts
new file mode 100644
index 0000000..86d6be3
--- /dev/null
+++ b/src/components/chat/tools/components/ContentRenderers/index.ts
@@ -0,0 +1,5 @@
+export { MarkdownContent } from './MarkdownContent';
+export { FileListContent } from './FileListContent';
+export { TodoListContent } from './TodoListContent';
+export { TaskListContent } from './TaskListContent';
+export { TextContent } from './TextContent';
diff --git a/src/components/chat/tools/components/DiffViewer.tsx b/src/components/chat/tools/components/DiffViewer.tsx
new file mode 100644
index 0000000..626c784
--- /dev/null
+++ b/src/components/chat/tools/components/DiffViewer.tsx
@@ -0,0 +1,88 @@
+import React, { useMemo } from 'react';
+
+type DiffLine = {
+ type: string;
+ content: string;
+ lineNum: number;
+};
+
+interface DiffViewerProps {
+ oldContent: string;
+ newContent: string;
+ filePath: string;
+ createDiff: (oldStr: string, newStr: string) => DiffLine[];
+ onFileClick?: () => void;
+ badge?: string;
+ badgeColor?: 'gray' | 'green';
+}
+
+/**
+ * Compact diff viewer — VS Code-style
+ */
+export const DiffViewer: React.FC = ({
+ oldContent,
+ newContent,
+ filePath,
+ createDiff,
+ onFileClick,
+ badge = 'Diff',
+ badgeColor = 'gray'
+}) => {
+ const badgeClasses = badgeColor === 'green'
+ ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
+
+ const diffLines = useMemo(
+ () => createDiff(oldContent, newContent),
+ [createDiff, oldContent, newContent]
+ );
+
+ return (
+
+ {/* Header */}
+
+ {onFileClick ? (
+
+ {filePath}
+
+ ) : (
+
+ {filePath}
+
+ )}
+
+ {badge}
+
+
+
+ {/* Diff lines */}
+
+ {diffLines.map((diffLine, i) => (
+
+
+ {diffLine.type === 'removed' ? '-' : '+'}
+
+
+ {diffLine.content}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx
new file mode 100644
index 0000000..52fc0f1
--- /dev/null
+++ b/src/components/chat/tools/components/OneLineDisplay.tsx
@@ -0,0 +1,233 @@
+import React, { useState } from 'react';
+
+type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
+
+interface OneLineDisplayProps {
+
+ toolName: string;
+ icon?: string;
+ label?: string;
+ value: string;
+ secondary?: string;
+ action?: ActionType;
+ onAction?: () => void;
+ style?: string;
+ wrapText?: boolean;
+ colorScheme?: {
+ primary?: string;
+ secondary?: string;
+ background?: string;
+ border?: string;
+ icon?: string;
+ };
+ resultId?: string;
+ toolResult?: any;
+ toolId?: string;
+}
+
+// Fallback for environments where the async Clipboard API is unavailable or blocked.
+const copyWithLegacyExecCommand = (text: string): boolean => {
+ if (typeof document === 'undefined' || !document.body) {
+ return false;
+ }
+
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ textarea.setSelectionRange(0, text.length);
+
+ let copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch {
+ copied = false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+
+ return copied;
+};
+
+const copyTextToClipboard = async (text: string): Promise => {
+ if (
+ typeof navigator !== 'undefined' &&
+ typeof window !== 'undefined' &&
+ window.isSecureContext &&
+ navigator.clipboard?.writeText
+ ) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
+ }
+ }
+
+ return copyWithLegacyExecCommand(text);
+};
+
+/**
+ * Unified one-line display for simple tool inputs and results
+ * Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
+ */
+export const OneLineDisplay: React.FC = ({
+ toolName,
+ icon,
+ label,
+ value,
+ secondary,
+ action = 'none',
+ onAction,
+ style,
+ wrapText = false,
+ colorScheme = {
+ primary: 'text-gray-700 dark:text-gray-300',
+ secondary: 'text-gray-500 dark:text-gray-400',
+ background: '',
+ border: 'border-gray-300 dark:border-gray-600',
+ icon: 'text-gray-500 dark:text-gray-400'
+ },
+ resultId,
+ toolResult,
+ toolId
+}) => {
+ const [copied, setCopied] = useState(false);
+ const isTerminal = style === 'terminal';
+
+ const handleAction = async () => {
+ if (action === 'copy' && value) {
+ const didCopy = await copyTextToClipboard(value);
+ if (!didCopy) {
+ return;
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else if (onAction) {
+ onAction();
+ }
+ };
+
+ const renderCopyButton = () => (
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+
+ // Terminal style: dark pill only around the command
+ if (isTerminal) {
+ return (
+
+
+
+
+
+
+ $ {value}
+
+
+ {action === 'copy' && renderCopyButton()}
+
+
+ {secondary && (
+
+
+ {secondary}
+
+
+ )}
+
+ );
+ }
+
+ // File open style - show filename only, full path on hover
+ if (action === 'open-file') {
+ const displayName = value.split('/').pop() || value;
+ return (
+
+ {label || toolName}
+ /
+
+ {displayName}
+
+
+ );
+ }
+
+ // Search / jump-to-results style
+ if (action === 'jump-to-results') {
+ return (
+
+
{label || toolName}
+
/
+
+ {value}
+
+ {secondary && (
+
+ {secondary}
+
+ )}
+ {toolResult && (
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ // Default one-line style
+ return (
+
+ {icon && icon !== 'terminal' && (
+ {icon}
+ )}
+ {!icon && (label || toolName) && (
+ {label || toolName}
+ )}
+ {(icon || label || toolName) && (
+ /
+ )}
+
+ {value}
+
+ {secondary && (
+
+ {secondary}
+
+ )}
+ {action === 'copy' && renderCopyButton()}
+
+ );
+};
diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts
new file mode 100644
index 0000000..fce0c5e
--- /dev/null
+++ b/src/components/chat/tools/components/index.ts
@@ -0,0 +1,5 @@
+export { CollapsibleSection } from './CollapsibleSection';
+export { DiffViewer } from './DiffViewer';
+export { OneLineDisplay } from './OneLineDisplay';
+export { CollapsibleDisplay } from './CollapsibleDisplay';
+export * from './ContentRenderers';
diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts
new file mode 100644
index 0000000..3bb4219
--- /dev/null
+++ b/src/components/chat/tools/configs/toolConfigs.ts
@@ -0,0 +1,569 @@
+/**
+ * Centralized tool configuration registry
+ * Defines display behavior for all tool types
+ */
+
+export interface ToolDisplayConfig {
+ input: {
+ type: 'one-line' | 'collapsible' | 'hidden';
+ // One-line config
+ icon?: string;
+ label?: string;
+ getValue?: (input: any) => string;
+ getSecondary?: (input: any) => string | undefined;
+ action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
+ style?: string;
+ wrapText?: boolean;
+ colorScheme?: {
+ primary?: string;
+ secondary?: string;
+ background?: string;
+ border?: string;
+ icon?: string;
+ };
+ // Collapsible config
+ title?: string | ((input: any) => string);
+ defaultOpen?: boolean;
+ contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
+ getContentProps?: (input: any, helpers?: any) => any;
+ actionButton?: 'file-button' | 'none';
+ };
+ result?: {
+ hidden?: boolean;
+ hideOnSuccess?: boolean;
+ type?: 'one-line' | 'collapsible' | 'special';
+ title?: string | ((result: any) => string);
+ defaultOpen?: boolean;
+ // Special result handlers
+ contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
+ getMessage?: (result: any) => string;
+ getContentProps?: (result: any) => any;
+ };
+}
+
+export const TOOL_CONFIGS: Record = {
+ // ============================================================================
+ // COMMAND TOOLS
+ // ============================================================================
+
+ Bash: {
+ input: {
+ type: 'one-line',
+ icon: 'terminal',
+ getValue: (input) => input.command,
+ getSecondary: (input) => input.description,
+ action: 'copy',
+ style: 'terminal',
+ wrapText: true,
+ colorScheme: {
+ primary: 'text-green-400 font-mono',
+ secondary: 'text-gray-400',
+ background: '',
+ border: 'border-green-500 dark:border-green-400',
+ icon: 'text-green-500 dark:text-green-400'
+ }
+ },
+ result: {
+ hideOnSuccess: true,
+ type: 'special'
+ }
+ },
+
+ // ============================================================================
+ // FILE OPERATION TOOLS
+ // ============================================================================
+
+ Read: {
+ input: {
+ type: 'one-line',
+ label: 'Read',
+ getValue: (input) => input.file_path || '',
+ action: 'open-file',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ background: '',
+ border: 'border-gray-300 dark:border-gray-600',
+ icon: 'text-gray-500 dark:text-gray-400'
+ }
+ },
+ result: {
+ hidden: true
+ }
+ },
+
+ Edit: {
+ input: {
+ type: 'collapsible',
+ title: (input) => {
+ const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
+ return `${filename}`;
+ },
+ defaultOpen: false,
+ contentType: 'diff',
+ actionButton: 'none',
+ getContentProps: (input) => ({
+ oldContent: input.old_string,
+ newContent: input.new_string,
+ filePath: input.file_path,
+ badge: 'Edit',
+ badgeColor: 'gray'
+ })
+ },
+ result: {
+ hideOnSuccess: true
+ }
+ },
+
+ Write: {
+ input: {
+ type: 'collapsible',
+ title: (input) => {
+ const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
+ return `${filename}`;
+ },
+ defaultOpen: false,
+ contentType: 'diff',
+ actionButton: 'none',
+ getContentProps: (input) => ({
+ oldContent: '',
+ newContent: input.content,
+ filePath: input.file_path,
+ badge: 'New',
+ badgeColor: 'green'
+ })
+ },
+ result: {
+ hideOnSuccess: true
+ }
+ },
+
+ ApplyPatch: {
+ input: {
+ type: 'collapsible',
+ title: (input) => {
+ const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
+ return `${filename}`;
+ },
+ defaultOpen: false,
+ contentType: 'diff',
+ actionButton: 'none',
+ getContentProps: (input) => ({
+ oldContent: input.old_string,
+ newContent: input.new_string,
+ filePath: input.file_path,
+ badge: 'Patch',
+ badgeColor: 'gray'
+ })
+ },
+ result: {
+ hideOnSuccess: true
+ }
+ },
+
+ // ============================================================================
+ // SEARCH TOOLS
+ // ============================================================================
+
+ Grep: {
+ input: {
+ type: 'one-line',
+ label: 'Grep',
+ getValue: (input) => input.pattern,
+ getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
+ action: 'jump-to-results',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ secondary: 'text-gray-500 dark:text-gray-400',
+ background: '',
+ border: 'border-gray-400 dark:border-gray-500',
+ icon: 'text-gray-500 dark:text-gray-400'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ defaultOpen: false,
+ title: (result) => {
+ const toolData = result.toolUseResult || {};
+ const count = toolData.numFiles || toolData.filenames?.length || 0;
+ return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
+ },
+ contentType: 'file-list',
+ getContentProps: (result) => {
+ const toolData = result.toolUseResult || {};
+ return {
+ files: toolData.filenames || []
+ };
+ }
+ }
+ },
+
+ Glob: {
+ input: {
+ type: 'one-line',
+ label: 'Glob',
+ getValue: (input) => input.pattern,
+ getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
+ action: 'jump-to-results',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ secondary: 'text-gray-500 dark:text-gray-400',
+ background: '',
+ border: 'border-gray-400 dark:border-gray-500',
+ icon: 'text-gray-500 dark:text-gray-400'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ defaultOpen: false,
+ title: (result) => {
+ const toolData = result.toolUseResult || {};
+ const count = toolData.numFiles || toolData.filenames?.length || 0;
+ return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
+ },
+ contentType: 'file-list',
+ getContentProps: (result) => {
+ const toolData = result.toolUseResult || {};
+ return {
+ files: toolData.filenames || []
+ };
+ }
+ }
+ },
+
+ // ============================================================================
+ // TODO TOOLS
+ // ============================================================================
+
+ TodoWrite: {
+ input: {
+ type: 'collapsible',
+ title: 'Updating todo list',
+ defaultOpen: false,
+ contentType: 'todo-list',
+ getContentProps: (input) => ({
+ todos: input.todos
+ })
+ },
+ result: {
+ type: 'collapsible',
+ contentType: 'success-message',
+ getMessage: () => 'Todo list updated'
+ }
+ },
+
+ TodoRead: {
+ input: {
+ type: 'one-line',
+ label: 'TodoRead',
+ getValue: () => 'reading list',
+ action: 'none',
+ colorScheme: {
+ primary: 'text-gray-500 dark:text-gray-400',
+ border: 'border-violet-400 dark:border-violet-500'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ contentType: 'todo-list',
+ getContentProps: (result) => {
+ try {
+ const content = String(result.content || '');
+ let todos = null;
+ if (content.startsWith('[')) {
+ todos = JSON.parse(content);
+ }
+ return { todos, isResult: true };
+ } catch (e) {
+ return { todos: [], isResult: true };
+ }
+ }
+ }
+ },
+
+ // ============================================================================
+ // TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)
+ // ============================================================================
+
+ TaskCreate: {
+ input: {
+ type: 'one-line',
+ label: 'Task',
+ getValue: (input) => input.subject || 'Creating task',
+ getSecondary: (input) => input.status || undefined,
+ action: 'none',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ border: 'border-violet-400 dark:border-violet-500',
+ icon: 'text-violet-500 dark:text-violet-400'
+ }
+ },
+ result: {
+ hideOnSuccess: true
+ }
+ },
+
+ TaskUpdate: {
+ input: {
+ type: 'one-line',
+ label: 'Task',
+ getValue: (input) => {
+ const parts = [];
+ if (input.taskId) parts.push(`#${input.taskId}`);
+ if (input.status) parts.push(input.status);
+ if (input.subject) parts.push(`"${input.subject}"`);
+ return parts.join(' → ') || 'updating';
+ },
+ action: 'none',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ border: 'border-violet-400 dark:border-violet-500',
+ icon: 'text-violet-500 dark:text-violet-400'
+ }
+ },
+ result: {
+ hideOnSuccess: true
+ }
+ },
+
+ TaskList: {
+ input: {
+ type: 'one-line',
+ label: 'Tasks',
+ getValue: () => 'listing tasks',
+ action: 'none',
+ colorScheme: {
+ primary: 'text-gray-500 dark:text-gray-400',
+ border: 'border-violet-400 dark:border-violet-500',
+ icon: 'text-violet-500 dark:text-violet-400'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ defaultOpen: true,
+ title: 'Task list',
+ contentType: 'task',
+ getContentProps: (result) => ({
+ content: String(result?.content || '')
+ })
+ }
+ },
+
+ TaskGet: {
+ input: {
+ type: 'one-line',
+ label: 'Task',
+ getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',
+ action: 'none',
+ colorScheme: {
+ primary: 'text-gray-700 dark:text-gray-300',
+ border: 'border-violet-400 dark:border-violet-500',
+ icon: 'text-violet-500 dark:text-violet-400'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ defaultOpen: true,
+ title: 'Task details',
+ contentType: 'task',
+ getContentProps: (result) => ({
+ content: String(result?.content || '')
+ })
+ }
+ },
+
+ // ============================================================================
+ // SUBAGENT TASK TOOL
+ // ============================================================================
+
+ Task: {
+ input: {
+ type: 'collapsible',
+ title: (input) => {
+ const subagentType = input.subagent_type || 'Agent';
+ const description = input.description || 'Running task';
+ return `Subagent / ${subagentType}: ${description}`;
+ },
+ defaultOpen: true,
+ contentType: 'markdown',
+ getContentProps: (input) => {
+ // If only prompt exists (and required fields), show just the prompt
+ // Otherwise show all available fields
+ const hasOnlyPrompt = input.prompt &&
+ !input.model &&
+ !input.resume;
+
+ if (hasOnlyPrompt) {
+ return {
+ content: input.prompt || ''
+ };
+ }
+
+ // Format multiple fields
+ const parts = [];
+
+ if (input.model) {
+ parts.push(`**Model:** ${input.model}`);
+ }
+
+ if (input.prompt) {
+ parts.push(`**Prompt:**\n${input.prompt}`);
+ }
+
+ if (input.resume) {
+ parts.push(`**Resuming from:** ${input.resume}`);
+ }
+
+ return {
+ content: parts.join('\n\n')
+ };
+ },
+ colorScheme: {
+ border: 'border-purple-500 dark:border-purple-400',
+ icon: 'text-purple-500 dark:text-purple-400'
+ }
+ },
+ result: {
+ type: 'collapsible',
+ title: (result) => {
+ // Check if result has content with type array (agent results often have this structure)
+ if (result && result.content && Array.isArray(result.content)) {
+ return 'Subagent Response';
+ }
+ return 'Subagent Result';
+ },
+ defaultOpen: true,
+ contentType: 'markdown',
+ getContentProps: (result) => {
+ // Handle agent results which may have complex structure
+ if (result && result.content) {
+ // If content is an array (typical for agent responses with multiple text blocks)
+ if (Array.isArray(result.content)) {
+ const textContent = result.content
+ .filter((item: any) => item.type === 'text')
+ .map((item: any) => item.text)
+ .join('\n\n');
+ return { content: textContent || 'No response text' };
+ }
+ // If content is already a string
+ return { content: String(result.content) };
+ }
+ // Fallback to string representation
+ return { content: String(result || 'No response') };
+ }
+ }
+ },
+
+ // ============================================================================
+ // PLAN TOOLS
+ // ============================================================================
+
+ exit_plan_mode: {
+ input: {
+ type: 'collapsible',
+ title: 'Implementation plan',
+ defaultOpen: true,
+ contentType: 'markdown',
+ getContentProps: (input) => ({
+ content: input.plan?.replace(/\\n/g, '\n') || input.plan
+ })
+ },
+ result: {
+ type: 'collapsible',
+ contentType: 'markdown',
+ getContentProps: (result) => {
+ try {
+ let parsed = result.content;
+ if (typeof parsed === 'string') {
+ parsed = JSON.parse(parsed);
+ }
+ return {
+ content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
+ };
+ } catch (e) {
+ return { content: '' };
+ }
+ }
+ }
+ },
+
+ // Also register as ExitPlanMode (the actual tool name used by Claude)
+ ExitPlanMode: {
+ input: {
+ type: 'collapsible',
+ title: 'Implementation plan',
+ defaultOpen: true,
+ contentType: 'markdown',
+ getContentProps: (input) => ({
+ content: input.plan?.replace(/\\n/g, '\n') || input.plan
+ })
+ },
+ result: {
+ type: 'collapsible',
+ contentType: 'markdown',
+ getContentProps: (result) => {
+ try {
+ let parsed = result.content;
+ if (typeof parsed === 'string') {
+ parsed = JSON.parse(parsed);
+ }
+ return {
+ content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
+ };
+ } catch (e) {
+ return { content: '' };
+ }
+ }
+ }
+ },
+
+ // ============================================================================
+ // DEFAULT FALLBACK
+ // ============================================================================
+
+ Default: {
+ input: {
+ type: 'collapsible',
+ title: 'Parameters',
+ defaultOpen: false,
+ contentType: 'text',
+ getContentProps: (input) => ({
+ content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),
+ format: 'code'
+ })
+ },
+ result: {
+ type: 'collapsible',
+ contentType: 'text',
+ getContentProps: (result) => ({
+ content: String(result?.content || ''),
+ format: 'plain'
+ })
+ }
+ }
+};
+
+/**
+ * Get configuration for a tool, with fallback to default
+ */
+export function getToolConfig(toolName: string): ToolDisplayConfig {
+ return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;
+}
+
+/**
+ * Check if a tool result should be hidden
+ */
+export function shouldHideToolResult(toolName: string, toolResult: any): boolean {
+ const config = getToolConfig(toolName);
+
+ if (!config.result) return false;
+
+ // Always hidden
+ if (config.result.hidden) return true;
+
+ // Hide on success only
+ if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/components/chat/tools/index.ts b/src/components/chat/tools/index.ts
new file mode 100644
index 0000000..ea10a51
--- /dev/null
+++ b/src/components/chat/tools/index.ts
@@ -0,0 +1,3 @@
+export { ToolRenderer } from './ToolRenderer';
+export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
+export * from './components';
diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts
new file mode 100644
index 0000000..de166e0
--- /dev/null
+++ b/src/components/chat/types/types.ts
@@ -0,0 +1,92 @@
+import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
+
+export type Provider = SessionProvider;
+
+export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
+
+export interface ChatImage {
+ data: string;
+ name: string;
+}
+
+export interface ToolResult {
+ content?: unknown;
+ isError?: boolean;
+ timestamp?: string | number | Date;
+ toolUseResult?: unknown;
+ [key: string]: unknown;
+}
+
+export interface ChatMessage {
+ type: string;
+ content?: string;
+ timestamp: string | number | Date;
+ images?: ChatImage[];
+ reasoning?: string;
+ isThinking?: boolean;
+ isStreaming?: boolean;
+ isInteractivePrompt?: boolean;
+ isToolUse?: boolean;
+ toolName?: string;
+ toolInput?: unknown;
+ toolResult?: ToolResult | null;
+ toolId?: string;
+ toolCallId?: string;
+ [key: string]: unknown;
+}
+
+export interface ClaudeSettings {
+ allowedTools: string[];
+ disallowedTools: string[];
+ skipPermissions: boolean;
+ projectSortOrder: string;
+ lastUpdated?: string;
+ [key: string]: unknown;
+}
+
+export interface ClaudePermissionSuggestion {
+ toolName: string;
+ entry: string;
+ isAllowed: boolean;
+}
+
+export interface PermissionGrantResult {
+ success: boolean;
+ alreadyAllowed?: boolean;
+ updatedSettings?: ClaudeSettings;
+}
+
+export interface PendingPermissionRequest {
+ requestId: string;
+ toolName: string;
+ input?: unknown;
+ context?: unknown;
+ sessionId?: string | null;
+ receivedAt?: Date;
+}
+
+export interface ChatInterfaceProps {
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ ws: WebSocket | null;
+ sendMessage: (message: unknown) => void;
+ latestMessage: any;
+ onFileOpen?: (filePath: string, diffInfo?: any) => void;
+ onInputFocusChange?: (focused: boolean) => void;
+ onSessionActive?: (sessionId?: string | null) => void;
+ onSessionInactive?: (sessionId?: string | null) => void;
+ onSessionProcessing?: (sessionId?: string | null) => void;
+ onSessionNotProcessing?: (sessionId?: string | null) => void;
+ processingSessions?: Set;
+ onReplaceTemporarySession?: (sessionId?: string | null) => void;
+ onNavigateToSession?: (targetSessionId: string) => void;
+ onShowSettings?: () => void;
+ autoExpandTools?: boolean;
+ showRawParameters?: boolean;
+ showThinking?: boolean;
+ autoScrollToBottom?: boolean;
+ sendByCtrlEnter?: boolean;
+ externalMessageUpdate?: number;
+ onTaskClick?: (...args: unknown[]) => void;
+ onShowAllTasks?: (() => void) | null;
+}
diff --git a/src/components/chat/utils/chatFormatting.ts b/src/components/chat/utils/chatFormatting.ts
new file mode 100644
index 0000000..d0bb529
--- /dev/null
+++ b/src/components/chat/utils/chatFormatting.ts
@@ -0,0 +1,86 @@
+export function decodeHtmlEntities(text: string) {
+ if (!text) return text;
+ return text
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&');
+}
+
+export function normalizeInlineCodeFences(text: string) {
+ if (!text || typeof text !== 'string') return text;
+ try {
+ return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
+ } catch {
+ return text;
+ }
+}
+
+export function unescapeWithMathProtection(text: string) {
+ if (!text || typeof text !== 'string') return text;
+
+ const mathBlocks: string[] = [];
+ const placeholderPrefix = '__MATH_BLOCK_';
+ const placeholderSuffix = '__';
+
+ let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
+ const index = mathBlocks.length;
+ mathBlocks.push(match);
+ return `${placeholderPrefix}${index}${placeholderSuffix}`;
+ });
+
+ processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
+
+ processedText = processedText.replace(
+ new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
+ (match, index) => {
+ return mathBlocks[parseInt(index, 10)];
+ },
+ );
+
+ return processedText;
+}
+
+export function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export function formatUsageLimitText(text: string) {
+ 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;
+ const reset = new Date(timestampMs);
+
+ const timeStr = new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(reset);
+
+ 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, (char) => char.toUpperCase());
+ const tzHuman = city ? `${gmt} (${city})` : gmt;
+
+ 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;
+ }
+}
diff --git a/src/components/chat/utils/chatPermissions.ts b/src/components/chat/utils/chatPermissions.ts
new file mode 100644
index 0000000..f44d117
--- /dev/null
+++ b/src/components/chat/utils/chatPermissions.ts
@@ -0,0 +1,64 @@
+import { safeJsonParse } from '../../../lib/utils.js';
+import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types/types.js';
+import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
+
+export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
+ 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;
+
+ if (tokens[0] === 'git' && tokens[1]) {
+ return `Bash(${tokens[0]} ${tokens[1]}:*)`;
+ }
+ return `Bash(${tokens[0]}:*)`;
+}
+
+export function formatToolInputForDisplay(input: unknown) {
+ if (input === undefined || input === null) return '';
+ if (typeof input === 'string') return input;
+ try {
+ return JSON.stringify(input, null, 2);
+ } catch {
+ return String(input);
+ }
+}
+
+export function getClaudePermissionSuggestion(
+ message: ChatMessage | null | undefined,
+ provider: string,
+): ClaudePermissionSuggestion | null {
+ 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: toolName || 'UnknownTool', entry, isAllowed };
+}
+
+export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
+ 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 };
+}
diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts
new file mode 100644
index 0000000..d1ae327
--- /dev/null
+++ b/src/components/chat/utils/chatStorage.ts
@@ -0,0 +1,105 @@
+import type { ClaudeSettings } from '../types/types';
+
+export const CLAUDE_SETTINGS_KEY = 'claude-settings';
+
+export const safeLocalStorage = {
+ setItem: (key: string, value: string) => {
+ try {
+ if (key.startsWith('chat_messages_') && typeof value === 'string') {
+ try {
+ const parsed = JSON.parse(value);
+ if (Array.isArray(parsed) && parsed.length > 50) {
+ 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: any) {
+ if (error?.name === 'QuotaExceededError') {
+ console.warn('localStorage quota exceeded, clearing old data');
+
+ const keys = Object.keys(localStorage);
+ const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
+
+ if (chatKeys.length > 3) {
+ chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
+ localStorage.removeItem(k);
+ });
+ }
+
+ const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
+ draftKeys.forEach((k) => {
+ localStorage.removeItem(k);
+ });
+
+ try {
+ localStorage.setItem(key, value);
+ } catch (retryError) {
+ console.error('Failed to save to localStorage even after cleanup:', retryError);
+ 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));
+ }
+ } catch (finalError) {
+ console.error('Final save attempt failed:', finalError);
+ }
+ }
+ }
+ } else {
+ console.error('localStorage error:', error);
+ }
+ }
+ },
+ getItem: (key: string): string | null => {
+ try {
+ return localStorage.getItem(key);
+ } catch (error) {
+ console.error('localStorage getItem error:', error);
+ return null;
+ }
+ },
+ removeItem: (key: string) => {
+ try {
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.error('localStorage removeItem error:', error);
+ }
+ },
+};
+
+export function getClaudeSettings(): ClaudeSettings {
+ 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',
+ };
+ }
+}
diff --git a/src/components/chat/utils/messageKeys.ts b/src/components/chat/utils/messageKeys.ts
new file mode 100644
index 0000000..410161f
--- /dev/null
+++ b/src/components/chat/utils/messageKeys.ts
@@ -0,0 +1,38 @@
+import type { ChatMessage } from '../types/types';
+
+const toMessageKeyPart = (value: unknown): string | null => {
+ if (typeof value !== 'string' && typeof value !== 'number') {
+ return null;
+ }
+
+ const normalized = String(value).trim();
+ return normalized.length > 0 ? normalized : null;
+};
+
+export const getIntrinsicMessageKey = (message: ChatMessage): string | null => {
+ const candidates = [
+ message.id,
+ message.messageId,
+ message.toolId,
+ message.toolCallId,
+ message.blobId,
+ message.rowid,
+ message.sequence,
+ ];
+
+ for (const candidate of candidates) {
+ const keyPart = toMessageKeyPart(candidate);
+ if (keyPart) {
+ return `message-${message.type}-${keyPart}`;
+ }
+ }
+
+ const timestamp = new Date(message.timestamp).getTime();
+ if (!Number.isFinite(timestamp)) {
+ return null;
+ }
+
+ const contentPreview = typeof message.content === 'string' ? message.content.slice(0, 48) : '';
+ const toolName = typeof message.toolName === 'string' ? message.toolName : '';
+ return `message-${message.type}-${timestamp}-${toolName}-${contentPreview}`;
+};
diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts
new file mode 100644
index 0000000..cd6cd1f
--- /dev/null
+++ b/src/components/chat/utils/messageTransforms.ts
@@ -0,0 +1,508 @@
+import type { ChatMessage } from '../types/types';
+import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
+
+export interface DiffLine {
+ type: 'added' | 'removed';
+ content: string;
+ lineNum: number;
+}
+
+export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
+
+type CursorBlob = {
+ id?: string;
+ sequence?: number;
+ rowid?: number;
+ content?: any;
+};
+
+const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
+
+const normalizeToolInput = (value: unknown): string => {
+ if (value === null || value === undefined || value === '') {
+ return '';
+ }
+
+ if (typeof value === 'string') {
+ return value;
+ }
+
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+};
+
+const toAbsolutePath = (projectPath: string, filePath?: string) => {
+ if (!filePath) {
+ return filePath;
+ }
+ return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
+};
+
+export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
+ const oldLines = oldStr.split('\n');
+ const newLines = newStr.split('\n');
+
+ // Use LCS alignment so insertions/deletions don't cascade into a full-file "changed" diff.
+ const lcsTable: number[][] = Array.from({ length: oldLines.length + 1 }, () =>
+ new Array(newLines.length + 1).fill(0),
+ );
+ for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
+ for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {
+ if (oldLines[oldIndex] === newLines[newIndex]) {
+ lcsTable[oldIndex][newIndex] = lcsTable[oldIndex + 1][newIndex + 1] + 1;
+ } else {
+ lcsTable[oldIndex][newIndex] = Math.max(
+ lcsTable[oldIndex + 1][newIndex],
+ lcsTable[oldIndex][newIndex + 1],
+ );
+ }
+ }
+ }
+
+ const diffLines: DiffLine[] = [];
+ let oldIndex = 0;
+ let newIndex = 0;
+
+ while (oldIndex < oldLines.length && newIndex < newLines.length) {
+ const oldLine = oldLines[oldIndex];
+ const newLine = newLines[newIndex];
+
+ if (oldLine === newLine) {
+ oldIndex += 1;
+ newIndex += 1;
+ continue;
+ }
+
+ if (lcsTable[oldIndex + 1][newIndex] >= lcsTable[oldIndex][newIndex + 1]) {
+ diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
+ oldIndex += 1;
+ continue;
+ }
+
+ diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
+ newIndex += 1;
+ }
+
+ while (oldIndex < oldLines.length) {
+ diffLines.push({ type: 'removed', content: oldLines[oldIndex], lineNum: oldIndex + 1 });
+ oldIndex += 1;
+ }
+
+ while (newIndex < newLines.length) {
+ diffLines.push({ type: 'added', content: newLines[newIndex], lineNum: newIndex + 1 });
+ newIndex += 1;
+ }
+
+ return diffLines;
+};
+
+export const createCachedDiffCalculator = (): DiffCalculator => {
+ const cache = new Map();
+
+ return (oldStr: string, newStr: string) => {
+ const key = JSON.stringify([oldStr, newStr]);
+ const cached = cache.get(key);
+ if (cached) {
+ return cached;
+ }
+
+ const calculated = calculateDiff(oldStr, newStr);
+ cache.set(key, calculated);
+ if (cache.size > 100) {
+ const firstKey = cache.keys().next().value;
+ if (firstKey) {
+ cache.delete(firstKey);
+ }
+ }
+ return calculated;
+ };
+};
+
+export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
+ const converted: ChatMessage[] = [];
+ const toolUseMap: Record = {};
+
+ for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
+ const blob = blobs[blobIdx];
+ const content = blob.content;
+ let text = '';
+ let role: ChatMessage['type'] = 'assistant';
+ let reasoningText: string | null = null;
+
+ try {
+ if (content?.role && content?.content) {
+ if (content.role === 'system') {
+ continue;
+ }
+
+ if (content.role === 'tool') {
+ const toolItems = asArray(content.content);
+ for (const item of toolItems) {
+ if (item?.type !== 'tool-result') {
+ continue;
+ }
+
+ const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
+ const toolCallId = item.toolCallId || content.id;
+ const result = item.result || '';
+
+ if (toolCallId && toolUseMap[toolCallId]) {
+ toolUseMap[toolCallId].toolResult = {
+ content: result,
+ isError: false,
+ };
+ } else {
+ converted.push({
+ type: 'assistant',
+ content: '',
+ timestamp: new Date(Date.now() + blobIdx * 1000),
+ blobId: blob.id,
+ sequence: blob.sequence,
+ rowid: blob.rowid,
+ isToolUse: true,
+ toolName,
+ toolId: toolCallId,
+ toolInput: normalizeToolInput(null),
+ toolResult: {
+ content: result,
+ isError: false,
+ },
+ });
+ }
+ }
+ continue;
+ }
+
+ role = content.role === 'user' ? 'user' : 'assistant';
+
+ if (Array.isArray(content.content)) {
+ const textParts: string[] = [];
+
+ for (const part of content.content) {
+ if (part?.type === 'text' && part?.text) {
+ textParts.push(decodeHtmlEntities(part.text));
+ continue;
+ }
+
+ if (part?.type === 'reasoning' && part?.text) {
+ reasoningText = decodeHtmlEntities(part.text);
+ continue;
+ }
+
+ if (part?.type === 'tool-call' || part?.type === 'tool_use') {
+ if (textParts.length > 0 || reasoningText) {
+ converted.push({
+ type: role,
+ content: textParts.join('\n'),
+ reasoning: reasoningText ?? undefined,
+ timestamp: new Date(Date.now() + blobIdx * 1000),
+ blobId: blob.id,
+ sequence: blob.sequence,
+ rowid: blob.rowid,
+ });
+ textParts.length = 0;
+ reasoningText = null;
+ }
+
+ const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
+ const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
+ const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
+ let toolInput = part.args || part.input;
+
+ if (toolName === 'Edit' && part.args) {
+ if (part.args.patch) {
+ const patchLines = String(part.args.patch).split('\n');
+ const oldLines: string[] = [];
+ const newLines: string[] = [];
+ let inPatch = false;
+
+ patchLines.forEach((line) => {
+ if (line.startsWith('@@')) {
+ inPatch = true;
+ return;
+ }
+ if (!inPatch) {
+ return;
+ }
+
+ if (line.startsWith('-')) {
+ oldLines.push(line.slice(1));
+ } else if (line.startsWith('+')) {
+ newLines.push(line.slice(1));
+ } else if (line.startsWith(' ')) {
+ oldLines.push(line.slice(1));
+ newLines.push(line.slice(1));
+ }
+ });
+
+ toolInput = {
+ file_path: toAbsolutePath(projectPath, part.args.file_path),
+ old_string: oldLines.join('\n') || part.args.patch,
+ new_string: newLines.join('\n') || part.args.patch,
+ };
+ } else {
+ toolInput = part.args;
+ }
+ } else if (toolName === 'Read' && part.args) {
+ const filePath = part.args.path || part.args.file_path;
+ toolInput = {
+ file_path: toAbsolutePath(projectPath, filePath),
+ };
+ } else if (toolName === 'Write' && part.args) {
+ const filePath = part.args.path || part.args.file_path;
+ toolInput = {
+ file_path: toAbsolutePath(projectPath, filePath),
+ content: part.args.contents || part.args.content,
+ };
+ }
+
+ const toolMessage: ChatMessage = {
+ type: 'assistant',
+ content: '',
+ timestamp: new Date(Date.now() + blobIdx * 1000),
+ blobId: blob.id,
+ sequence: blob.sequence,
+ rowid: blob.rowid,
+ isToolUse: true,
+ toolName,
+ toolId,
+ toolInput: normalizeToolInput(toolInput),
+ toolResult: null,
+ };
+ converted.push(toolMessage);
+ toolUseMap[toolId] = toolMessage;
+ continue;
+ }
+
+ if (typeof part === 'string') {
+ textParts.push(part);
+ }
+ }
+
+ if (textParts.length > 0) {
+ text = textParts.join('\n');
+ if (reasoningText && !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 = '';
+ }
+ } else {
+ text = '';
+ }
+ } else if (typeof content.content === 'string') {
+ text = content.content;
+ }
+ } else if (content?.message?.role && content?.message?.content) {
+ if (content.message.role === 'system') {
+ continue;
+ }
+
+ role = content.message.role === 'user' ? 'user' : 'assistant';
+ if (Array.isArray(content.message.content)) {
+ text = content.message.content
+ .map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
+ .filter(Boolean)
+ .join('\n');
+ } else if (typeof content.message.content === 'string') {
+ text = content.message.content;
+ }
+ }
+ } catch (error) {
+ console.log('Error parsing blob content:', error);
+ }
+
+ if (text && text.trim()) {
+ const message: ChatMessage = {
+ type: role,
+ content: text,
+ timestamp: new Date(Date.now() + blobIdx * 1000),
+ blobId: blob.id,
+ sequence: blob.sequence,
+ rowid: blob.rowid,
+ };
+ if (reasoningText) {
+ message.reasoning = reasoningText;
+ }
+ converted.push(message);
+ }
+ }
+
+ converted.sort((messageA, messageB) => {
+ if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
+ return Number(messageA.sequence) - Number(messageB.sequence);
+ }
+ if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
+ return Number(messageA.rowid) - Number(messageB.rowid);
+ }
+ return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
+ });
+
+ return converted;
+};
+
+export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
+ const converted: ChatMessage[] = [];
+ const toolResults = new Map<
+ string,
+ { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
+ >();
+
+ rawMessages.forEach((message) => {
+ if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
+ message.message.content.forEach((part: any) => {
+ if (part.type !== 'tool_result') {
+ return;
+ }
+ toolResults.set(part.tool_use_id, {
+ content: part.content,
+ isError: Boolean(part.is_error),
+ timestamp: new Date(message.timestamp || Date.now()),
+ toolUseResult: message.toolUseResult || null,
+ });
+ });
+ }
+ });
+
+ rawMessages.forEach((message) => {
+ if (message.message?.role === 'user' && message.message?.content) {
+ let content = '';
+ if (Array.isArray(message.message.content)) {
+ const textParts: string[] = [];
+ message.message.content.forEach((part: any) => {
+ if (part.type === 'text') {
+ textParts.push(decodeHtmlEntities(part.text));
+ }
+ });
+ content = textParts.join('\n');
+ } else if (typeof message.message.content === 'string') {
+ content = decodeHtmlEntities(message.message.content);
+ } else {
+ content = decodeHtmlEntities(String(message.message.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) {
+ converted.push({
+ type: 'user',
+ content: unescapeWithMathProtection(content),
+ timestamp: message.timestamp || new Date().toISOString(),
+ });
+ }
+ return;
+ }
+
+ if (message.type === 'thinking' && message.message?.content) {
+ converted.push({
+ type: 'assistant',
+ content: unescapeWithMathProtection(message.message.content),
+ timestamp: message.timestamp || new Date().toISOString(),
+ isThinking: true,
+ });
+ return;
+ }
+
+ if (message.type === 'tool_use' && message.toolName) {
+ converted.push({
+ type: 'assistant',
+ content: '',
+ timestamp: message.timestamp || new Date().toISOString(),
+ isToolUse: true,
+ toolName: message.toolName,
+ toolInput: normalizeToolInput(message.toolInput),
+ toolCallId: message.toolCallId,
+ });
+ return;
+ }
+
+ if (message.type === 'tool_result') {
+ for (let index = converted.length - 1; index >= 0; index -= 1) {
+ const convertedMessage = converted[index];
+ if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
+ continue;
+ }
+ if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
+ convertedMessage.toolResult = {
+ content: message.output || '',
+ isError: false,
+ };
+ break;
+ }
+ }
+ return;
+ }
+
+ if (message.message?.role === 'assistant' && message.message?.content) {
+ if (Array.isArray(message.message.content)) {
+ message.message.content.forEach((part: any) => {
+ if (part.type === 'text') {
+ let text = part.text;
+ if (typeof text === 'string') {
+ text = unescapeWithMathProtection(text);
+ }
+ converted.push({
+ type: 'assistant',
+ content: text,
+ timestamp: message.timestamp || new Date().toISOString(),
+ });
+ return;
+ }
+
+ if (part.type === 'tool_use') {
+ const toolResult = toolResults.get(part.id);
+ converted.push({
+ type: 'assistant',
+ content: '',
+ timestamp: message.timestamp || new Date().toISOString(),
+ isToolUse: true,
+ toolName: part.name,
+ toolInput: normalizeToolInput(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(),
+ });
+ }
+ });
+ return;
+ }
+
+ if (typeof message.message.content === 'string') {
+ converted.push({
+ type: 'assistant',
+ content: unescapeWithMathProtection(message.message.content),
+ timestamp: message.timestamp || new Date().toISOString(),
+ });
+ }
+ }
+ });
+
+ return converted;
+};
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
new file mode 100644
index 0000000..58bed6d
--- /dev/null
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -0,0 +1,384 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import QuickSettingsPanel from '../../QuickSettingsPanel';
+import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
+import { useTranslation } from 'react-i18next';
+import ChatMessagesPane from './subcomponents/ChatMessagesPane';
+import ChatComposer from './subcomponents/ChatComposer';
+import type { ChatInterfaceProps } from '../types/types';
+import { useChatProviderState } from '../hooks/useChatProviderState';
+import { useChatSessionState } from '../hooks/useChatSessionState';
+import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
+import { useChatComposerState } from '../hooks/useChatComposerState';
+import type { Provider } from '../types/types';
+
+type PendingViewSession = {
+ sessionId: string | null;
+ startedAt: number;
+};
+
+function ChatInterface({
+ selectedProject,
+ selectedSession,
+ ws,
+ sendMessage,
+ latestMessage,
+ onFileOpen,
+ onInputFocusChange,
+ onSessionActive,
+ onSessionInactive,
+ onSessionProcessing,
+ onSessionNotProcessing,
+ processingSessions,
+ onReplaceTemporarySession,
+ onNavigateToSession,
+ onShowSettings,
+ autoExpandTools,
+ showRawParameters,
+ showThinking,
+ autoScrollToBottom,
+ sendByCtrlEnter,
+ externalMessageUpdate,
+ onShowAllTasks,
+}: ChatInterfaceProps) {
+ const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
+ const { t } = useTranslation('chat');
+
+ const streamBufferRef = useRef('');
+ const streamTimerRef = useRef(null);
+ const pendingViewSessionRef = useRef(null);
+
+ const resetStreamingState = useCallback(() => {
+ if (streamTimerRef.current) {
+ clearTimeout(streamTimerRef.current);
+ streamTimerRef.current = null;
+ }
+ streamBufferRef.current = '';
+ }, []);
+
+ const {
+ provider,
+ setProvider,
+ cursorModel,
+ setCursorModel,
+ claudeModel,
+ setClaudeModel,
+ codexModel,
+ setCodexModel,
+ permissionMode,
+ pendingPermissionRequests,
+ setPendingPermissionRequests,
+ cyclePermissionMode,
+ } = useChatProviderState({
+ selectedSession,
+ });
+
+ const {
+ chatMessages,
+ setChatMessages,
+ isLoading,
+ setIsLoading,
+ currentSessionId,
+ setCurrentSessionId,
+ sessionMessages,
+ setSessionMessages,
+ isLoadingSessionMessages,
+ isLoadingMoreMessages,
+ hasMoreMessages,
+ totalMessages,
+ isSystemSessionChange,
+ setIsSystemSessionChange,
+ canAbortSession,
+ setCanAbortSession,
+ isUserScrolledUp,
+ setIsUserScrolledUp,
+ tokenBudget,
+ setTokenBudget,
+ visibleMessageCount,
+ visibleMessages,
+ loadEarlierMessages,
+ claudeStatus,
+ setClaudeStatus,
+ createDiff,
+ scrollContainerRef,
+ scrollToBottom,
+ handleScroll,
+ } = useChatSessionState({
+ selectedProject,
+ selectedSession,
+ ws,
+ sendMessage,
+ autoScrollToBottom,
+ externalMessageUpdate,
+ processingSessions,
+ resetStreamingState,
+ pendingViewSessionRef,
+ });
+
+ const {
+ input,
+ setInput,
+ textareaRef,
+ inputHighlightRef,
+ isTextareaExpanded,
+ thinkingMode,
+ setThinkingMode,
+ slashCommandsCount,
+ filteredCommands,
+ frequentCommands,
+ commandQuery,
+ showCommandMenu,
+ selectedCommandIndex,
+ resetCommandMenuState,
+ handleCommandSelect,
+ handleToggleCommandMenu,
+ showFileDropdown,
+ filteredFiles,
+ selectedFileIndex,
+ renderInputWithMentions,
+ selectFile,
+ attachedImages,
+ setAttachedImages,
+ uploadingImages,
+ imageErrors,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ openImagePicker,
+ handleSubmit,
+ handleInputChange,
+ handleKeyDown,
+ handlePaste,
+ handleTextareaClick,
+ handleTextareaInput,
+ syncInputOverlayScroll,
+ handleClearInput,
+ handleAbortSession,
+ handleTranscript,
+ handlePermissionDecision,
+ handleGrantToolPermission,
+ handleInputFocusChange,
+ } = useChatComposerState({
+ selectedProject,
+ selectedSession,
+ currentSessionId,
+ provider,
+ permissionMode,
+ cyclePermissionMode,
+ cursorModel,
+ claudeModel,
+ codexModel,
+ isLoading,
+ canAbortSession,
+ tokenBudget,
+ sendMessage,
+ sendByCtrlEnter,
+ onSessionActive,
+ onInputFocusChange,
+ onFileOpen,
+ onShowSettings,
+ pendingViewSessionRef,
+ scrollToBottom,
+ setChatMessages,
+ setSessionMessages,
+ setIsLoading,
+ setCanAbortSession,
+ setClaudeStatus,
+ setIsUserScrolledUp,
+ setPendingPermissionRequests,
+ });
+
+ useChatRealtimeHandlers({
+ latestMessage,
+ provider,
+ selectedProject,
+ selectedSession,
+ currentSessionId,
+ setCurrentSessionId,
+ setChatMessages,
+ setIsLoading,
+ setCanAbortSession,
+ setClaudeStatus,
+ setTokenBudget,
+ setIsSystemSessionChange,
+ setPendingPermissionRequests,
+ pendingViewSessionRef,
+ streamBufferRef,
+ streamTimerRef,
+ onSessionInactive,
+ onSessionProcessing,
+ onSessionNotProcessing,
+ onReplaceTemporarySession,
+ onNavigateToSession,
+ });
+
+ useEffect(() => {
+ if (!isLoading || !canAbortSession) {
+ return;
+ }
+
+ const handleGlobalEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {
+ return;
+ }
+
+ event.preventDefault();
+ handleAbortSession();
+ };
+
+ document.addEventListener('keydown', handleGlobalEscape, { capture: true });
+ return () => {
+ document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
+ };
+ }, [canAbortSession, handleAbortSession, isLoading]);
+
+ useEffect(() => {
+ const processingSessionId = selectedSession?.id || currentSessionId;
+ if (processingSessionId && isLoading && onSessionProcessing) {
+ onSessionProcessing(processingSessionId);
+ }
+ }, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
+
+ useEffect(() => {
+ return () => {
+ resetStreamingState();
+ };
+ }, [resetStreamingState]);
+
+ if (!selectedProject) {
+ const selectedProviderLabel =
+ provider === 'cursor'
+ ? t('messageTypes.cursor')
+ : provider === 'codex'
+ ? t('messageTypes.codex')
+ : t('messageTypes.claude');
+
+ return (
+
+
+
+ {t('projectSelection.startChatWithProvider', {
+ provider: selectedProviderLabel,
+ defaultValue: 'Select a project to start chatting with {{provider}}',
+ })}
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+ setProvider(nextProvider as Provider)}
+ textareaRef={textareaRef}
+ claudeModel={claudeModel}
+ setClaudeModel={setClaudeModel}
+ cursorModel={cursorModel}
+ setCursorModel={setCursorModel}
+ codexModel={codexModel}
+ setCodexModel={setCodexModel}
+ tasksEnabled={tasksEnabled}
+ isTaskMasterInstalled={isTaskMasterInstalled}
+ onShowAllTasks={onShowAllTasks}
+ setInput={setInput}
+ isLoadingMoreMessages={isLoadingMoreMessages}
+ hasMoreMessages={hasMoreMessages}
+ totalMessages={totalMessages}
+ sessionMessagesCount={sessionMessages.length}
+ visibleMessageCount={visibleMessageCount}
+ visibleMessages={visibleMessages}
+ loadEarlierMessages={loadEarlierMessages}
+ createDiff={createDiff}
+ onFileOpen={onFileOpen}
+ onShowSettings={onShowSettings}
+ onGrantToolPermission={handleGrantToolPermission}
+ autoExpandTools={autoExpandTools}
+ showRawParameters={showRawParameters}
+ showThinking={showThinking}
+ selectedProject={selectedProject}
+ isLoading={isLoading}
+ />
+
+ 0}
+ onScrollToBottom={scrollToBottom}
+ onSubmit={handleSubmit}
+ isDragActive={isDragActive}
+ attachedImages={attachedImages}
+ onRemoveImage={(index) =>
+ setAttachedImages((previous) =>
+ previous.filter((_, currentIndex) => currentIndex !== index),
+ )
+ }
+ uploadingImages={uploadingImages}
+ imageErrors={imageErrors}
+ showFileDropdown={showFileDropdown}
+ filteredFiles={filteredFiles}
+ selectedFileIndex={selectedFileIndex}
+ onSelectFile={selectFile}
+ filteredCommands={filteredCommands}
+ selectedCommandIndex={selectedCommandIndex}
+ onCommandSelect={handleCommandSelect}
+ onCloseCommandMenu={resetCommandMenuState}
+ isCommandMenuOpen={showCommandMenu}
+ frequentCommands={commandQuery ? [] : frequentCommands}
+ getRootProps={getRootProps as (...args: unknown[]) => Record}
+ getInputProps={getInputProps as (...args: unknown[]) => Record}
+ openImagePicker={openImagePicker}
+ inputHighlightRef={inputHighlightRef}
+ renderInputWithMentions={renderInputWithMentions}
+ textareaRef={textareaRef}
+ input={input}
+ onInputChange={handleInputChange}
+ onTextareaClick={handleTextareaClick}
+ onTextareaKeyDown={handleKeyDown}
+ onTextareaPaste={handlePaste}
+ onTextareaScrollSync={syncInputOverlayScroll}
+ onTextareaInput={handleTextareaInput}
+ onInputFocusChange={handleInputFocusChange}
+ placeholder={t('input.placeholder', {
+ provider:
+ provider === 'cursor'
+ ? t('messageTypes.cursor')
+ : provider === 'codex'
+ ? t('messageTypes.codex')
+ : t('messageTypes.claude'),
+ })}
+ isTextareaExpanded={isTextareaExpanded}
+ sendByCtrlEnter={sendByCtrlEnter}
+ onTranscript={handleTranscript}
+ />
+
+
+
+ >
+ );
+}
+
+export default React.memo(ChatInterface);
diff --git a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx
new file mode 100644
index 0000000..872dd0c
--- /dev/null
+++ b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx
@@ -0,0 +1,37 @@
+import { SessionProvider } from '../../../../types/app';
+import SessionProviderLogo from '../../../SessionProviderLogo';
+import type { Provider } from '../../types/types';
+
+type AssistantThinkingIndicatorProps = {
+ selectedProvider: SessionProvider;
+}
+
+
+export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
+ return (
+
+
+
+
+
+
+
+ {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
+
+
+
+
+
.
+
+ .
+
+
+ .
+
+
Thinking...
+
+
+
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx
new file mode 100644
index 0000000..13639bd
--- /dev/null
+++ b/src/components/chat/view/subcomponents/ChatComposer.tsx
@@ -0,0 +1,346 @@
+import CommandMenu from '../../../CommandMenu';
+import ClaudeStatus from '../../../ClaudeStatus';
+import { MicButton } from '../../../MicButton.jsx';
+import ImageAttachment from './ImageAttachment';
+import PermissionRequestsBanner from './PermissionRequestsBanner';
+import ChatInputControls from './ChatInputControls';
+import { useTranslation } from 'react-i18next';
+import type {
+ ChangeEvent,
+ ClipboardEvent,
+ Dispatch,
+ FormEvent,
+ KeyboardEvent,
+ MouseEvent,
+ ReactNode,
+ RefObject,
+ SetStateAction,
+ TouchEvent,
+} from 'react';
+import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
+
+interface MentionableFile {
+ name: string;
+ path: string;
+}
+
+interface SlashCommand {
+ name: string;
+ description?: string;
+ namespace?: string;
+ path?: string;
+ type?: string;
+ metadata?: Record;
+ [key: string]: unknown;
+}
+
+interface ChatComposerProps {
+ pendingPermissionRequests: PendingPermissionRequest[];
+ handlePermissionDecision: (
+ requestIds: string | string[],
+ decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
+ ) => void;
+ handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
+ claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
+ isLoading: boolean;
+ onAbortSession: () => void;
+ provider: Provider | string;
+ permissionMode: PermissionMode | string;
+ onModeSwitch: () => void;
+ thinkingMode: string;
+ setThinkingMode: Dispatch>;
+ tokenBudget: { used?: number; total?: number } | null;
+ slashCommandsCount: number;
+ onToggleCommandMenu: () => void;
+ hasInput: boolean;
+ onClearInput: () => void;
+ isUserScrolledUp: boolean;
+ hasMessages: boolean;
+ onScrollToBottom: () => void;
+ onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void;
+ isDragActive: boolean;
+ attachedImages: File[];
+ onRemoveImage: (index: number) => void;
+ uploadingImages: Map;
+ imageErrors: Map;
+ showFileDropdown: boolean;
+ filteredFiles: MentionableFile[];
+ selectedFileIndex: number;
+ onSelectFile: (file: MentionableFile) => void;
+ filteredCommands: SlashCommand[];
+ selectedCommandIndex: number;
+ onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;
+ onCloseCommandMenu: () => void;
+ isCommandMenuOpen: boolean;
+ frequentCommands: SlashCommand[];
+ getRootProps: (...args: unknown[]) => Record;
+ getInputProps: (...args: unknown[]) => Record;
+ openImagePicker: () => void;
+ inputHighlightRef: RefObject;
+ renderInputWithMentions: (text: string) => ReactNode;
+ textareaRef: RefObject;
+ input: string;
+ onInputChange: (event: ChangeEvent) => void;
+ onTextareaClick: (event: MouseEvent) => void;
+ onTextareaKeyDown: (event: KeyboardEvent) => void;
+ onTextareaPaste: (event: ClipboardEvent) => void;
+ onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
+ onTextareaInput: (event: FormEvent) => void;
+ onInputFocusChange?: (focused: boolean) => void;
+ placeholder: string;
+ isTextareaExpanded: boolean;
+ sendByCtrlEnter?: boolean;
+ onTranscript: (text: string) => void;
+}
+
+export default function ChatComposer({
+ pendingPermissionRequests,
+ handlePermissionDecision,
+ handleGrantToolPermission,
+ claudeStatus,
+ isLoading,
+ onAbortSession,
+ provider,
+ permissionMode,
+ onModeSwitch,
+ thinkingMode,
+ setThinkingMode,
+ tokenBudget,
+ slashCommandsCount,
+ onToggleCommandMenu,
+ hasInput,
+ onClearInput,
+ isUserScrolledUp,
+ hasMessages,
+ onScrollToBottom,
+ onSubmit,
+ isDragActive,
+ attachedImages,
+ onRemoveImage,
+ uploadingImages,
+ imageErrors,
+ showFileDropdown,
+ filteredFiles,
+ selectedFileIndex,
+ onSelectFile,
+ filteredCommands,
+ selectedCommandIndex,
+ onCommandSelect,
+ onCloseCommandMenu,
+ isCommandMenuOpen,
+ frequentCommands,
+ getRootProps,
+ getInputProps,
+ openImagePicker,
+ inputHighlightRef,
+ renderInputWithMentions,
+ textareaRef,
+ input,
+ onInputChange,
+ onTextareaClick,
+ onTextareaKeyDown,
+ onTextareaPaste,
+ onTextareaScrollSync,
+ onTextareaInput,
+ onInputFocusChange,
+ placeholder,
+ isTextareaExpanded,
+ sendByCtrlEnter,
+ onTranscript,
+}: ChatComposerProps) {
+ const { t } = useTranslation('chat');
+ const AnyCommandMenu = CommandMenu as any;
+ const textareaRect = textareaRef.current?.getBoundingClientRect();
+ const commandMenuPosition = {
+ top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
+ left: textareaRect ? textareaRect.left : 16,
+ bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
+ };
+
+ return (
+
+
+
+
+
+
+
+
) => void} className="relative max-w-4xl mx-auto">
+ {isDragActive && (
+
+
+
+
+
+
Drop images here
+
+
+ )}
+
+ {attachedImages.length > 0 && (
+
+
+ {attachedImages.map((file, index) => (
+ onRemoveImage(index)}
+ uploadProgress={uploadingImages.get(file.name)}
+ error={imageErrors.get(file.name)}
+ />
+ ))}
+
+
+ )}
+
+ {showFileDropdown && filteredFiles.length > 0 && (
+
+ {filteredFiles.map((file, index) => (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onSelectFile(file);
+ }}
+ >
+
{file.name}
+
{file.path}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {renderInputWithMentions(input)}
+
+
+
+
+
onTextareaScrollSync(event.target as HTMLTextAreaElement)}
+ onFocus={() => onInputFocusChange?.(true)}
+ onBlur={() => onInputFocusChange?.(false)}
+ onInput={onTextareaInput}
+ placeholder={placeholder}
+ disabled={isLoading}
+ className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
+ style={{ height: '50px' }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ onSubmit(event);
+ }}
+ onTouchStart={(event) => {
+ event.preventDefault();
+ onSubmit(event);
+ }}
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
+ >
+
+
+
+
+
+
+ {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
+
+
+
+
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/ChatInputControls.tsx b/src/components/chat/view/subcomponents/ChatInputControls.tsx
new file mode 100644
index 0000000..d4b9a70
--- /dev/null
+++ b/src/components/chat/view/subcomponents/ChatInputControls.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import ThinkingModeSelector from './ThinkingModeSelector';
+import TokenUsagePie from './TokenUsagePie';
+import type { PermissionMode, Provider } from '../../types/types';
+
+interface ChatInputControlsProps {
+ permissionMode: PermissionMode | string;
+ onModeSwitch: () => void;
+ provider: Provider | string;
+ thinkingMode: string;
+ setThinkingMode: React.Dispatch>;
+ tokenBudget: { used?: number; total?: number } | null;
+ slashCommandsCount: number;
+ onToggleCommandMenu: () => void;
+ hasInput: boolean;
+ onClearInput: () => void;
+ isUserScrolledUp: boolean;
+ hasMessages: boolean;
+ onScrollToBottom: () => void;
+}
+
+export default function ChatInputControls({
+ permissionMode,
+ onModeSwitch,
+ provider,
+ thinkingMode,
+ setThinkingMode,
+ tokenBudget,
+ slashCommandsCount,
+ onToggleCommandMenu,
+ hasInput,
+ onClearInput,
+ isUserScrolledUp,
+ hasMessages,
+ onScrollToBottom,
+}: ChatInputControlsProps) {
+ const { t } = useTranslation('chat');
+
+ return (
+
+
+
+
+
+ {permissionMode === 'default' && t('codex.modes.default')}
+ {permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
+ {permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
+ {permissionMode === 'plan' && t('codex.modes.plan')}
+
+
+
+
+ {provider === 'claude' && (
+
{}} className="" />
+ )}
+
+
+
+
+
+
+
+ {slashCommandsCount > 0 && (
+
+ {slashCommandsCount}
+
+ )}
+
+
+ {hasInput && (
+
+
+
+
+
+ )}
+
+ {isUserScrolledUp && hasMessages && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
new file mode 100644
index 0000000..21ad51f
--- /dev/null
+++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
@@ -0,0 +1,208 @@
+import { useTranslation } from 'react-i18next';
+import { useCallback, useRef } from 'react';
+import type { Dispatch, RefObject, SetStateAction } from 'react';
+
+import MessageComponent from './MessageComponent';
+import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
+import type { ChatMessage } from '../../types/types';
+import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
+import AssistantThinkingIndicator from './AssistantThinkingIndicator';
+import { getIntrinsicMessageKey } from '../../utils/messageKeys';
+
+interface ChatMessagesPaneProps {
+ scrollContainerRef: RefObject;
+ onWheel: () => void;
+ onTouchMove: () => void;
+ isLoadingSessionMessages: boolean;
+ chatMessages: ChatMessage[];
+ selectedSession: ProjectSession | null;
+ currentSessionId: string | null;
+ provider: SessionProvider;
+ setProvider: (provider: SessionProvider) => void;
+ textareaRef: RefObject;
+ claudeModel: string;
+ setClaudeModel: (model: string) => void;
+ cursorModel: string;
+ setCursorModel: (model: string) => void;
+ codexModel: string;
+ setCodexModel: (model: string) => void;
+ tasksEnabled: boolean;
+ isTaskMasterInstalled: boolean | null;
+ onShowAllTasks?: (() => void) | null;
+ setInput: Dispatch>;
+ isLoadingMoreMessages: boolean;
+ hasMoreMessages: boolean;
+ totalMessages: number;
+ sessionMessagesCount: number;
+ visibleMessageCount: number;
+ visibleMessages: ChatMessage[];
+ loadEarlierMessages: () => void;
+ createDiff: any;
+ onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
+ onShowSettings?: () => void;
+ onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
+ autoExpandTools?: boolean;
+ showRawParameters?: boolean;
+ showThinking?: boolean;
+ selectedProject: Project;
+ isLoading: boolean;
+}
+
+export default function ChatMessagesPane({
+ scrollContainerRef,
+ onWheel,
+ onTouchMove,
+ isLoadingSessionMessages,
+ chatMessages,
+ selectedSession,
+ currentSessionId,
+ provider,
+ setProvider,
+ textareaRef,
+ claudeModel,
+ setClaudeModel,
+ cursorModel,
+ setCursorModel,
+ codexModel,
+ setCodexModel,
+ tasksEnabled,
+ isTaskMasterInstalled,
+ onShowAllTasks,
+ setInput,
+ isLoadingMoreMessages,
+ hasMoreMessages,
+ totalMessages,
+ sessionMessagesCount,
+ visibleMessageCount,
+ visibleMessages,
+ loadEarlierMessages,
+ createDiff,
+ onFileOpen,
+ onShowSettings,
+ onGrantToolPermission,
+ autoExpandTools,
+ showRawParameters,
+ showThinking,
+ selectedProject,
+ isLoading,
+}: ChatMessagesPaneProps) {
+ const { t } = useTranslation('chat');
+ const messageKeyMapRef = useRef>(new WeakMap());
+ const allocatedKeysRef = useRef>(new Set());
+ const generatedMessageKeyCounterRef = useRef(0);
+
+ // Keep keys stable across prepends so existing MessageComponent instances retain local state.
+ const getMessageKey = useCallback((message: ChatMessage) => {
+ const existingKey = messageKeyMapRef.current.get(message);
+ if (existingKey) {
+ return existingKey;
+ }
+
+ const intrinsicKey = getIntrinsicMessageKey(message);
+ let candidateKey = intrinsicKey;
+
+ if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
+ do {
+ generatedMessageKeyCounterRef.current += 1;
+ candidateKey = intrinsicKey
+ ? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
+ : `message-generated-${generatedMessageKeyCounterRef.current}`;
+ } while (allocatedKeysRef.current.has(candidateKey));
+ }
+
+ allocatedKeysRef.current.add(candidateKey);
+ messageKeyMapRef.current.set(message, candidateKey);
+ return candidateKey;
+ }, []);
+
+ return (
+
+ {isLoadingSessionMessages && chatMessages.length === 0 ? (
+
+
+
+
{t('session.loading.sessionMessages')}
+
+
+ ) : chatMessages.length === 0 ? (
+
+ ) : (
+ <>
+ {isLoadingMoreMessages && (
+
+
+
+
{t('session.loading.olderMessages')}
+
+
+ )}
+
+ {hasMoreMessages && !isLoadingMoreMessages && (
+
+ {totalMessages > 0 && (
+
+ {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })} |
+ {t('session.messages.scrollToLoad')}
+
+ )}
+
+ )}
+
+ {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
+
+ {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
+
+ {t('session.messages.loadEarlier')}
+
+
+ )}
+
+ {visibleMessages.map((message, index) => {
+ const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
+ return (
+
+ );
+ })}
+ >
+ )}
+
+ {isLoading &&
}
+
+ );
+}
+
diff --git a/src/components/chat/view/subcomponents/ImageAttachment.tsx b/src/components/chat/view/subcomponents/ImageAttachment.tsx
new file mode 100644
index 0000000..eda2d31
--- /dev/null
+++ b/src/components/chat/view/subcomponents/ImageAttachment.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+
+interface ImageAttachmentProps {
+ file: File;
+ onRemove: () => void;
+ uploadProgress?: number;
+ error?: string;
+}
+
+const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {
+ const [preview, setPreview] = useState(undefined);
+
+ useEffect(() => {
+ const url = URL.createObjectURL(file);
+ setPreview(url);
+ return () => URL.revokeObjectURL(url);
+ }, [file]);
+
+ return (
+
+
+ {uploadProgress !== undefined && uploadProgress < 100 && (
+
+ )}
+ {error && (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default ImageAttachment;
+
+
diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx
new file mode 100644
index 0000000..7a0e43c
--- /dev/null
+++ b/src/components/chat/view/subcomponents/Markdown.tsx
@@ -0,0 +1,188 @@
+import React, { useMemo, useState } 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 { useTranslation } from 'react-i18next';
+import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
+
+type MarkdownProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+type CodeBlockProps = {
+ node?: any;
+ inline?: boolean;
+ className?: string;
+ children?: React.ReactNode;
+};
+
+const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
+ const { t } = useTranslation('chat');
+ const [copied, setCopied] = 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;
+
+ if (shouldInline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ 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(() => {
+ 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 {}
+ };
+
+ return (
+
+ {language && language !== 'text' && (
+
{language}
+ )}
+
+
+ {copied ? (
+
+
+
+
+ {t('codeBlock.copied')}
+
+ ) : (
+
+
+
+
+
+ {t('codeBlock.copy')}
+
+ )}
+
+
+
+ {raw}
+
+
+ );
+};
+
+const markdownComponents = {
+ code: CodeBlock,
+ blockquote: ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ p: ({ children }: { children?: React.ReactNode }) => {children}
,
+ table: ({ children }: { children?: React.ReactNode }) => (
+
+ ),
+ thead: ({ children }: { children?: React.ReactNode }) => {children} ,
+ th: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ td: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+};
+
+export function Markdown({ children, className }: MarkdownProps) {
+ const content = normalizeInlineCodeFences(String(children ?? ''));
+ const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
+ const rehypePlugins = useMemo(() => [rehypeKatex], []);
+
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
new file mode 100644
index 0000000..a90332b
--- /dev/null
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -0,0 +1,446 @@
+import React, { memo, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import SessionProviderLogo from '../../../SessionProviderLogo';
+import type {
+ ChatMessage,
+ ClaudePermissionSuggestion,
+ PermissionGrantResult,
+ Provider,
+} from '../../types/types';
+import { Markdown } from './Markdown';
+import { formatUsageLimitText } from '../../utils/chatFormatting';
+import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
+import type { Project } from '../../../../types/app';
+import { ToolRenderer, shouldHideToolResult } from '../../tools';
+
+type DiffLine = {
+ type: string;
+ content: string;
+ lineNum: number;
+};
+
+interface MessageComponentProps {
+ message: ChatMessage;
+ index: number;
+ prevMessage: ChatMessage | null;
+ createDiff: (oldStr: string, newStr: string) => DiffLine[];
+ onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
+ onShowSettings?: () => void;
+ onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
+ autoExpandTools?: boolean;
+ showRawParameters?: boolean;
+ showThinking?: boolean;
+ selectedProject?: Project | null;
+ provider: Provider | string;
+}
+
+type InteractiveOption = {
+ number: string;
+ text: string;
+ isSelected: boolean;
+};
+
+type PermissionGrantState = 'idle' | 'granted' | 'error';
+
+const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
+ 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(() => {
+ const node = messageRef.current;
+ if (!autoExpandTools || !node || !message.isToolUse) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting && !isExpanded) {
+ setIsExpanded(true);
+ const details = node.querySelectorAll('details');
+ details.forEach((detail) => {
+ detail.open = true;
+ });
+ }
+ });
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(node);
+
+ return () => {
+ observer.unobserve(node);
+ };
+ }, [autoExpandTools, isExpanded, message.isToolUse]);
+
+ const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
+ const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
+
+ if (shouldHideThinkingMessage) {
+ return null;
+ }
+
+ return (
+
+ {message.type === 'user' ? (
+ /* User message bubble on the right */
+
+
+
+ {message.content}
+
+ {message.images && message.images.length > 0 && (
+
+ {message.images.map((img, idx) => (
+
window.open(img.data, '_blank')}
+ />
+ ))}
+
+ )}
+
+ {formattedTime}
+
+
+ {!isGrouped && (
+
+ U
+
+ )}
+
+ ) : (
+ /* Claude/Error/Tool messages on the left */
+
+ {!isGrouped && (
+
+ {message.type === 'error' ? (
+
+ !
+
+ ) : message.type === 'tool' ? (
+
+ 🔧
+
+ ) : (
+
+
+
+ )}
+
+ {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
+
+
+ )}
+
+
+
+ {message.isToolUse ? (
+ <>
+
+
+
+ {String(message.displayText || '')}
+
+
+
+
+ {message.toolInput && (
+
+ )}
+
+ {/* Tool Result Section */}
+ {message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
+ message.toolResult.isError ? (
+ // Error results - red error box with content
+
+ ) : (
+ // Non-error results - route through ToolRenderer (single source of truth)
+
+
+
+ )
+ )}
+ >
+ ) : message.isInteractivePrompt ? (
+ // Special handling for interactive prompts
+
+
+
+
+
+ {t('interactive.title')}
+
+ {(() => {
+ const lines = (message.content || '').split('\n').filter((line) => line.trim());
+ const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
+ const options: InteractiveOption[] = [];
+
+ // 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) => (
+
+
+
+ {option.number}
+
+
+ {option.text}
+
+ {option.isSelected && (
+ ❯
+ )}
+
+
+ ))}
+
+
+
+
+ {t('interactive.waiting')}
+
+
+ {t('interactive.instruction')}
+
+
+ >
+ );
+ })()}
+
+
+
+ ) : message.isThinking ? (
+ /* Thinking messages - collapsible by default */
+
+
+
+
+
+
+ {t('thinking.emoji')}
+
+
+
+ {message.content}
+
+
+
+
+ ) : (
+
+ {/* Thinking accordion for reasoning */}
+ {showThinking && message.reasoning && (
+
+
+ {t('thinking.emoji')}
+
+
+
+ {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 (
+
+
+
+
+
+
{t('json.response')}
+
+
+
+ );
+ } catch {
+ // Not valid JSON, fall through to normal rendering
+ }
+ }
+
+ // Normal rendering for non-JSON content
+ return message.type === 'assistant' ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ );
+ })()}
+
+ )}
+
+ {!isGrouped && (
+
+ {formattedTime}
+
+ )}
+
+
+ )}
+
+ );
+});
+
+export default MessageComponent;
+
diff --git a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx
new file mode 100644
index 0000000..cfe86d3
--- /dev/null
+++ b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import type { PendingPermissionRequest } from '../../types/types';
+import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
+import { getClaudeSettings } from '../../utils/chatStorage';
+
+interface PermissionRequestsBannerProps {
+ pendingPermissionRequests: PendingPermissionRequest[];
+ handlePermissionDecision: (
+ requestIds: string | string[],
+ decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
+ ) => void;
+ handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
+}
+
+export default function PermissionRequestsBanner({
+ pendingPermissionRequests,
+ handlePermissionDecision,
+ handleGrantToolPermission,
+}: PermissionRequestsBannerProps) {
+ if (!pendingPermissionRequests.length) {
+ return null;
+ }
+
+ return (
+
+ {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';
+ 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}
+
+
+ )}
+
+
+ handlePermissionDecision(request.requestId, { allow: true })}
+ className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
+ >
+ Allow once
+
+ {
+ if (permissionEntry && !alreadyAllowed) {
+ handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
+ }
+ handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
+ }}
+ className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
+ permissionEntry
+ ? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
+ : 'border-gray-300 text-gray-400 cursor-not-allowed'
+ }`}
+ disabled={!permissionEntry}
+ >
+ {rememberLabel}
+
+ handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
+ className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
+ >
+ Deny
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
new file mode 100644
index 0000000..35d0572
--- /dev/null
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import SessionProviderLogo from '../../../SessionProviderLogo';
+import NextTaskBanner from '../../../NextTaskBanner.jsx';
+import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
+import type { ProjectSession, SessionProvider } from '../../../../types/app';
+
+interface ProviderSelectionEmptyStateProps {
+ selectedSession: ProjectSession | null;
+ currentSessionId: string | null;
+ provider: SessionProvider;
+ setProvider: (next: SessionProvider) => void;
+ textareaRef: React.RefObject;
+ claudeModel: string;
+ setClaudeModel: (model: string) => void;
+ cursorModel: string;
+ setCursorModel: (model: string) => void;
+ codexModel: string;
+ setCodexModel: (model: string) => void;
+ tasksEnabled: boolean;
+ isTaskMasterInstalled: boolean | null;
+ onShowAllTasks?: (() => void) | null;
+ setInput: React.Dispatch>;
+}
+
+export default function ProviderSelectionEmptyState({
+ selectedSession,
+ currentSessionId,
+ provider,
+ setProvider,
+ textareaRef,
+ claudeModel,
+ setClaudeModel,
+ cursorModel,
+ setCursorModel,
+ codexModel,
+ setCodexModel,
+ tasksEnabled,
+ isTaskMasterInstalled,
+ onShowAllTasks,
+ setInput,
+}: ProviderSelectionEmptyStateProps) {
+ const { t } = useTranslation('chat');
+ // Reuse one translated prompt so task-start behavior stays consistent across empty and session states.
+ const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
+
+ const selectProvider = (nextProvider: SessionProvider) => {
+ setProvider(nextProvider);
+ localStorage.setItem('selected-provider', nextProvider);
+ setTimeout(() => textareaRef.current?.focus(), 100);
+ };
+
+ return (
+
+ {!selectedSession && !currentSessionId && (
+
+
{t('providerSelection.title')}
+
{t('providerSelection.description')}
+
+
+
selectProvider('claude')}
+ className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
+ provider === 'claude'
+ ? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
+ }`}
+ >
+
+
+
+
Claude Code
+
{t('providerSelection.providerInfo.anthropic')}
+
+
+ {provider === 'claude' && (
+
+ )}
+
+
+
selectProvider('cursor')}
+ className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
+ provider === 'cursor'
+ ? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
+ }`}
+ >
+
+
+
+
Cursor
+
{t('providerSelection.providerInfo.cursorEditor')}
+
+
+ {provider === 'cursor' && (
+
+ )}
+
+
+
selectProvider('codex')}
+ className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
+ provider === 'codex'
+ ? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
+ }`}
+ >
+
+
+
+
Codex
+
{t('providerSelection.providerInfo.openai')}
+
+
+ {provider === 'codex' && (
+
+ )}
+
+
+
+
+ {t('providerSelection.selectModel')}
+ {provider === 'claude' ? (
+ {
+ const newModel = e.target.value;
+ setClaudeModel(newModel);
+ localStorage.setItem('claude-model', newModel);
+ }}
+ className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
+ >
+ {CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+ ) : provider === 'codex' ? (
+ {
+ const newModel = e.target.value;
+ setCodexModel(newModel);
+ localStorage.setItem('codex-model', newModel);
+ }}
+ className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]"
+ >
+ {CODEX_MODELS.OPTIONS.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+ ) : (
+ {
+ const newModel = e.target.value;
+ setCursorModel(newModel);
+ localStorage.setItem('cursor-model', newModel);
+ }}
+ className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
+ disabled={provider !== 'cursor'}
+ >
+ {CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+ )}
+
+
+
+ {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')}
+
+
+ {provider && tasksEnabled && isTaskMasterInstalled && (
+
+ setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
+
+ )}
+
+ )}
+ {selectedSession && (
+
+
{t('session.continue.title')}
+
{t('session.continue.description')}
+
+ {tasksEnabled && isTaskMasterInstalled && (
+
+ setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/ThinkingModeSelector.jsx b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
similarity index 75%
rename from src/components/ThinkingModeSelector.jsx
rename to src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
index 5cc6357..e0ae4d3 100644
--- a/src/components/ThinkingModeSelector.jsx
+++ b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
@@ -1,55 +1,21 @@
-import React, { useState, useRef, useEffect } from 'react';
-import { Brain, Zap, Sparkles, Atom, X } from 'lucide-react';
+import { useState, useRef, useEffect } from 'react';
+import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
-const thinkingModes = [
- {
- id: 'none',
- name: 'Standard',
- description: 'Regular Claude response',
- icon: null,
- prefix: '',
- color: 'text-gray-600'
- },
- {
- id: 'think',
- name: 'Think',
- description: 'Basic extended thinking',
- icon: Brain,
- prefix: 'think',
- color: 'text-blue-600'
- },
- {
- id: 'think-hard',
- name: 'Think Hard',
- description: 'More thorough evaluation',
- icon: Zap,
- prefix: 'think hard',
- color: 'text-purple-600'
- },
- {
- id: 'think-harder',
- name: 'Think Harder',
- description: 'Deep analysis with alternatives',
- icon: Sparkles,
- prefix: 'think harder',
- color: 'text-indigo-600'
- },
- {
- id: 'ultrathink',
- name: 'Ultrathink',
- description: 'Maximum thinking budget',
- icon: Atom,
- prefix: 'ultrathink',
- color: 'text-red-600'
- }
-];
+import { thinkingModes } from '../../constants/thinkingModes';
-function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }) {
+type ThinkingModeSelectorProps = {
+ selectedMode: string;
+ onModeChange: (modeId: string) => void;
+ onClose?: () => void;
+ className?: string;
+};
+
+function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat');
// Mapping from mode ID to translation key
- const modeKeyMap = {
+ const modeKeyMap: Record = {
'think-hard': 'thinkHard',
'think-harder': 'thinkHarder'
};
@@ -65,11 +31,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
});
const [isOpen, setIsOpen] = useState(false);
- const dropdownRef = useRef(null);
+ const dropdownRef = useRef(null);
useEffect(() => {
- const handleClickOutside = (event) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
if (onClose) onClose();
}
@@ -87,11 +53,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
setIsOpen(!isOpen)}
- className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${
- selectedMode === 'none'
+ className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
- }`}
+ }`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
>
@@ -123,7 +88,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
{translatedModes.map((mode) => {
const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode;
-
+
return (
@@ -142,9 +106,8 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
-
+
{mode.name}
{isSelected && (
@@ -179,5 +142,4 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
);
}
-export default ThinkingModeSelector;
-export { thinkingModes };
\ No newline at end of file
+export default ThinkingModeSelector;
\ No newline at end of file
diff --git a/src/components/TokenUsagePie.jsx b/src/components/chat/view/subcomponents/TokenUsagePie.tsx
similarity index 87%
rename from src/components/TokenUsagePie.jsx
rename to src/components/chat/view/subcomponents/TokenUsagePie.tsx
index 20df3b4..09dd89c 100644
--- a/src/components/TokenUsagePie.jsx
+++ b/src/components/chat/view/subcomponents/TokenUsagePie.tsx
@@ -1,9 +1,12 @@
-import React from 'react';
+type TokenUsagePieProps = {
+ used: number;
+ total: number;
+};
-function TokenUsagePie({ used, total }) {
+export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
// Token usage visualization component
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
- if (used == null || total == null || total <= 0) return null;
+ if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
@@ -48,6 +51,4 @@ function TokenUsagePie({ used, total }) {
);
-}
-
-export default TokenUsagePie;
+}
\ No newline at end of file
diff --git a/src/components/main-content/hooks/useEditorSidebar.ts b/src/components/main-content/hooks/useEditorSidebar.ts
new file mode 100644
index 0000000..e55e334
--- /dev/null
+++ b/src/components/main-content/hooks/useEditorSidebar.ts
@@ -0,0 +1,110 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { MouseEvent as ReactMouseEvent } from 'react';
+import type { Project } from '../../../types/app';
+import type { DiffInfo, EditingFile } from '../types/types';
+
+type UseEditorSidebarOptions = {
+ selectedProject: Project | null;
+ isMobile: boolean;
+ initialWidth?: number;
+};
+
+export function useEditorSidebar({
+ selectedProject,
+ isMobile,
+ initialWidth = 600,
+}: UseEditorSidebarOptions) {
+ const [editingFile, setEditingFile] = useState
(null);
+ const [editorWidth, setEditorWidth] = useState(initialWidth);
+ const [editorExpanded, setEditorExpanded] = useState(false);
+ const [isResizing, setIsResizing] = useState(false);
+ const resizeHandleRef = useRef(null);
+
+ const handleFileOpen = useCallback(
+ (filePath: string, diffInfo: DiffInfo | null = null) => {
+ const normalizedPath = filePath.replace(/\\/g, '/');
+ const fileName = normalizedPath.split('/').pop() || filePath;
+
+ setEditingFile({
+ name: fileName,
+ path: filePath,
+ projectName: selectedProject?.name,
+ diffInfo,
+ });
+ },
+ [selectedProject?.name],
+ );
+
+ const handleCloseEditor = useCallback(() => {
+ setEditingFile(null);
+ setEditorExpanded(false);
+ }, []);
+
+ const handleToggleEditorExpand = useCallback(() => {
+ setEditorExpanded((prev) => !prev);
+ }, []);
+
+ const handleResizeStart = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isMobile) {
+ return;
+ }
+
+ setIsResizing(true);
+ event.preventDefault();
+ },
+ [isMobile],
+ );
+
+ useEffect(() => {
+ const handleMouseMove = (event: globalThis.MouseEvent) => {
+ if (!isResizing) {
+ return;
+ }
+
+ const container = resizeHandleRef.current?.parentElement;
+ if (!container) {
+ return;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const newWidth = containerRect.right - event.clientX;
+
+ const minWidth = 300;
+ const maxWidth = containerRect.width * 0.8;
+
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
+ setEditorWidth(newWidth);
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ if (isResizing) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ }, [isResizing]);
+
+ return {
+ editingFile,
+ editorWidth,
+ editorExpanded,
+ resizeHandleRef,
+ handleFileOpen,
+ handleCloseEditor,
+ handleToggleEditorExpand,
+ handleResizeStart,
+ };
+}
diff --git a/src/components/main-content/hooks/useMobileMenuHandlers.ts b/src/components/main-content/hooks/useMobileMenuHandlers.ts
new file mode 100644
index 0000000..d9472b5
--- /dev/null
+++ b/src/components/main-content/hooks/useMobileMenuHandlers.ts
@@ -0,0 +1,50 @@
+import { useCallback, useRef } from 'react';
+import type { MouseEvent, TouchEvent } from 'react';
+
+type MenuEvent = MouseEvent | TouchEvent;
+
+export function useMobileMenuHandlers(onMenuClick: () => void) {
+ const suppressNextMenuClickRef = useRef(false);
+
+ const openMobileMenu = useCallback(
+ (event?: MenuEvent) => {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ onMenuClick();
+ },
+ [onMenuClick],
+ );
+
+ const handleMobileMenuTouchEnd = useCallback(
+ (event: TouchEvent) => {
+ suppressNextMenuClickRef.current = true;
+ openMobileMenu(event);
+
+ window.setTimeout(() => {
+ suppressNextMenuClickRef.current = false;
+ }, 350);
+ },
+ [openMobileMenu],
+ );
+
+ const handleMobileMenuClick = useCallback(
+ (event: MouseEvent) => {
+ if (suppressNextMenuClickRef.current) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ openMobileMenu(event);
+ },
+ [openMobileMenu],
+ );
+
+ return {
+ handleMobileMenuClick,
+ handleMobileMenuTouchEnd,
+ };
+}
diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts
new file mode 100644
index 0000000..c91e60e
--- /dev/null
+++ b/src/components/main-content/types/types.ts
@@ -0,0 +1,107 @@
+import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
+import type { AppTab, Project, ProjectSession } from '../../../types/app';
+
+export type SessionLifecycleHandler = (sessionId?: string | null) => void;
+
+export interface DiffInfo {
+ old_string?: string;
+ new_string?: string;
+ [key: string]: unknown;
+}
+
+export interface EditingFile {
+ name: string;
+ path: string;
+ projectName?: string;
+ diffInfo?: DiffInfo | null;
+ [key: string]: unknown;
+}
+
+export interface TaskMasterTask {
+ id: string | number;
+ title?: string;
+ description?: string;
+ status?: string;
+ priority?: string;
+ details?: string;
+ testStrategy?: string;
+ parentId?: string | number;
+ dependencies?: Array;
+ subtasks?: TaskMasterTask[];
+ [key: string]: unknown;
+}
+
+export interface TaskReference {
+ id: string | number;
+ title?: string;
+ [key: string]: unknown;
+}
+
+export type TaskSelection = TaskMasterTask | TaskReference;
+
+export interface PrdFile {
+ name: string;
+ content?: string;
+ isExisting?: boolean;
+ [key: string]: unknown;
+}
+
+export interface MainContentProps {
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ activeTab: AppTab;
+ setActiveTab: Dispatch>;
+ ws: WebSocket | null;
+ sendMessage: (message: unknown) => void;
+ latestMessage: unknown;
+ isMobile: boolean;
+ onMenuClick: () => void;
+ isLoading: boolean;
+ onInputFocusChange: (focused: boolean) => void;
+ onSessionActive: SessionLifecycleHandler;
+ onSessionInactive: SessionLifecycleHandler;
+ onSessionProcessing: SessionLifecycleHandler;
+ onSessionNotProcessing: SessionLifecycleHandler;
+ processingSessions: Set;
+ onReplaceTemporarySession: SessionLifecycleHandler;
+ onNavigateToSession: (targetSessionId: string) => void;
+ onShowSettings: () => void;
+ externalMessageUpdate: number;
+}
+
+export interface MainContentHeaderProps {
+ activeTab: AppTab;
+ setActiveTab: Dispatch>;
+ selectedProject: Project;
+ selectedSession: ProjectSession | null;
+ shouldShowTasksTab: boolean;
+ isMobile: boolean;
+ onMenuClick: () => void;
+}
+
+export interface MainContentStateViewProps {
+ mode: 'loading' | 'empty';
+ isMobile: boolean;
+ onMenuClick: () => void;
+}
+
+export interface MobileMenuButtonProps {
+ onMenuClick: () => void;
+ compact?: boolean;
+}
+
+export interface EditorSidebarProps {
+ editingFile: EditingFile | null;
+ isMobile: boolean;
+ editorExpanded: boolean;
+ editorWidth: number;
+ resizeHandleRef: RefObject;
+ onResizeStart: (event: MouseEvent) => void;
+ onCloseEditor: () => void;
+ onToggleEditorExpand: () => void;
+ projectPath?: string;
+}
+
+export interface TaskMasterPanelProps {
+ isVisible: boolean;
+}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
new file mode 100644
index 0000000..1db3315
--- /dev/null
+++ b/src/components/main-content/view/MainContent.tsx
@@ -0,0 +1,181 @@
+import React, { useEffect } from 'react';
+
+import ChatInterface from '../../chat/view/ChatInterface';
+import FileTree from '../../FileTree';
+import StandaloneShell from '../../StandaloneShell';
+import GitPanel from '../../GitPanel';
+import ErrorBoundary from '../../ErrorBoundary';
+
+import MainContentHeader from './subcomponents/MainContentHeader';
+import MainContentStateView from './subcomponents/MainContentStateView';
+import EditorSidebar from './subcomponents/EditorSidebar';
+import TaskMasterPanel from './subcomponents/TaskMasterPanel';
+import type { MainContentProps } from '../types/types';
+
+import { useTaskMaster } from '../../../contexts/TaskMasterContext';
+import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
+import { useUiPreferences } from '../../../hooks/useUiPreferences';
+import { useEditorSidebar } from '../hooks/useEditorSidebar';
+import type { Project } from '../../../types/app';
+
+const AnyStandaloneShell = StandaloneShell as any;
+const AnyGitPanel = GitPanel as any;
+
+type TaskMasterContextValue = {
+ currentProject?: Project | null;
+ setCurrentProject?: ((project: Project) => void) | null;
+};
+
+type TasksSettingsContextValue = {
+ tasksEnabled: boolean;
+ isTaskMasterInstalled: boolean | null;
+ isTaskMasterReady: boolean | null;
+};
+
+function MainContent({
+ selectedProject,
+ selectedSession,
+ activeTab,
+ setActiveTab,
+ ws,
+ sendMessage,
+ latestMessage,
+ isMobile,
+ onMenuClick,
+ isLoading,
+ onInputFocusChange,
+ onSessionActive,
+ onSessionInactive,
+ onSessionProcessing,
+ onSessionNotProcessing,
+ processingSessions,
+ onReplaceTemporarySession,
+ onNavigateToSession,
+ onShowSettings,
+ externalMessageUpdate,
+}: MainContentProps) {
+ const { preferences } = useUiPreferences();
+ const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
+
+ const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
+ const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
+
+ const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
+
+ const {
+ editingFile,
+ editorWidth,
+ editorExpanded,
+ resizeHandleRef,
+ handleFileOpen,
+ handleCloseEditor,
+ handleToggleEditorExpand,
+ handleResizeStart,
+ } = useEditorSidebar({
+ selectedProject,
+ isMobile,
+ });
+
+ useEffect(() => {
+ if (selectedProject && selectedProject !== currentProject) {
+ setCurrentProject?.(selectedProject);
+ }
+ }, [selectedProject, currentProject, setCurrentProject]);
+
+ useEffect(() => {
+ if (!shouldShowTasksTab && activeTab === 'tasks') {
+ setActiveTab('chat');
+ }
+ }, [shouldShowTasksTab, activeTab, setActiveTab]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!selectedProject) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ setActiveTab('tasks') : null}
+ />
+
+
+
+ {activeTab === 'files' && (
+
+
+
+ )}
+
+ {activeTab === 'shell' && (
+
+ )}
+
+ {activeTab === 'git' && (
+
+ )}
+
+ {shouldShowTasksTab &&
}
+
+
+
+
+
+
+
+ );
+}
+
+export default React.memo(MainContent);
diff --git a/src/components/main-content/view/subcomponents/EditorSidebar.tsx b/src/components/main-content/view/subcomponents/EditorSidebar.tsx
new file mode 100644
index 0000000..591a3b8
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/EditorSidebar.tsx
@@ -0,0 +1,60 @@
+import CodeEditor from '../../../CodeEditor';
+import type { EditorSidebarProps } from '../../types/types';
+
+const AnyCodeEditor = CodeEditor as any;
+
+export default function EditorSidebar({
+ editingFile,
+ isMobile,
+ editorExpanded,
+ editorWidth,
+ resizeHandleRef,
+ onResizeStart,
+ onCloseEditor,
+ onToggleEditorExpand,
+ projectPath,
+}: EditorSidebarProps) {
+ if (!editingFile) {
+ return null;
+ }
+
+ if (isMobile) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {!editorExpanded && (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/MainContentHeader.tsx b/src/components/main-content/view/subcomponents/MainContentHeader.tsx
new file mode 100644
index 0000000..d1cc74a
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/MainContentHeader.tsx
@@ -0,0 +1,38 @@
+import MobileMenuButton from './MobileMenuButton';
+import MainContentTabSwitcher from './MainContentTabSwitcher';
+import MainContentTitle from './MainContentTitle';
+import type { MainContentHeaderProps } from '../../types/types';
+
+export default function MainContentHeader({
+ activeTab,
+ setActiveTab,
+ selectedProject,
+ selectedSession,
+ shouldShowTasksTab,
+ isMobile,
+ onMenuClick,
+}: MainContentHeaderProps) {
+ return (
+
+
+
+ {isMobile && }
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/MainContentStateView.tsx b/src/components/main-content/view/subcomponents/MainContentStateView.tsx
new file mode 100644
index 0000000..ada5c52
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/MainContentStateView.tsx
@@ -0,0 +1,55 @@
+import { useTranslation } from 'react-i18next';
+import MobileMenuButton from './MobileMenuButton';
+import type { MainContentStateViewProps } from '../../types/types';
+
+export default function MainContentStateView({ mode, isMobile, onMenuClick }: MainContentStateViewProps) {
+ const { t } = useTranslation();
+
+ const isLoading = mode === 'loading';
+
+ return (
+
+ {isMobile && (
+
+
+
+ )}
+
+ {isLoading ? (
+
+
+
+
{t('mainContent.loading')}
+
{t('mainContent.settingUpWorkspace')}
+
+
+ ) : (
+
+
+
+
{t('mainContent.chooseProject')}
+
{t('mainContent.selectProjectDescription')}
+
+
+ {t('mainContent.tip')}: {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
new file mode 100644
index 0000000..f1938be
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
@@ -0,0 +1,84 @@
+import Tooltip from '../../../Tooltip';
+import type { AppTab } from '../../../../types/app';
+import type { Dispatch, SetStateAction } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type MainContentTabSwitcherProps = {
+ activeTab: AppTab;
+ setActiveTab: Dispatch>;
+ shouldShowTasksTab: boolean;
+};
+
+type TabDefinition = {
+ id: AppTab;
+ labelKey: string;
+ iconPath: string;
+};
+
+const BASE_TABS: TabDefinition[] = [
+ {
+ id: 'chat',
+ labelKey: 'tabs.chat',
+ iconPath:
+ 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
+ },
+ {
+ id: 'shell',
+ labelKey: 'tabs.shell',
+ iconPath: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z',
+ },
+ {
+ id: 'files',
+ labelKey: 'tabs.files',
+ iconPath: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z',
+ },
+ {
+ id: 'git',
+ labelKey: 'tabs.git',
+ iconPath: 'M13 10V3L4 14h7v7l9-11h-7z',
+ },
+];
+
+const TASKS_TAB: TabDefinition = {
+ id: 'tasks',
+ labelKey: 'tabs.tasks',
+ iconPath:
+ 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
+};
+
+function getButtonClasses(tabId: AppTab, activeTab: AppTab) {
+ const base = 'relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200';
+
+ if (tabId === activeTab) {
+ return `${base} bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm`;
+ }
+
+ return `${base} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700`;
+}
+
+export default function MainContentTabSwitcher({
+ activeTab,
+ setActiveTab,
+ shouldShowTasksTab,
+}: MainContentTabSwitcherProps) {
+ const { t } = useTranslation();
+
+ const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
+
+ return (
+
+ {tabs.map((tab) => (
+
+ setActiveTab(tab.id)} className={getButtonClasses(tab.id, activeTab)}>
+
+
+
+
+ {t(tab.labelKey)}
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
new file mode 100644
index 0000000..174c467
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
@@ -0,0 +1,79 @@
+import { useTranslation } from 'react-i18next';
+import SessionProviderLogo from '../../../SessionProviderLogo';
+import type { AppTab, Project, ProjectSession } from '../../../../types/app';
+
+type MainContentTitleProps = {
+ activeTab: AppTab;
+ selectedProject: Project;
+ selectedSession: ProjectSession | null;
+ shouldShowTasksTab: boolean;
+};
+
+function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
+ if (activeTab === 'files') {
+ return t('mainContent.projectFiles');
+ }
+
+ if (activeTab === 'git') {
+ return t('tabs.git');
+ }
+
+ if (activeTab === 'tasks' && shouldShowTasksTab) {
+ return 'TaskMaster';
+ }
+
+ return 'Project';
+}
+
+function getSessionTitle(session: ProjectSession): string {
+ if (session.__provider === 'cursor') {
+ return (session.name as string) || 'Untitled Session';
+ }
+
+ return (session.summary as string) || 'New Session';
+}
+
+export default function MainContentTitle({
+ activeTab,
+ selectedProject,
+ selectedSession,
+ shouldShowTasksTab,
+}: MainContentTitleProps) {
+ const { t } = useTranslation();
+
+ const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
+ const showChatNewSession = activeTab === 'chat' && !selectedSession;
+
+ return (
+
+ {showSessionIcon && (
+
+
+
+ )}
+
+
+ {activeTab === 'chat' && selectedSession ? (
+
+
+ {getSessionTitle(selectedSession)}
+
+
{selectedProject.displayName}
+
+ ) : showChatNewSession ? (
+
+
{t('mainContent.newSession')}
+
{selectedProject.displayName}
+
+ ) : (
+
+
+ {getTabTitle(activeTab, shouldShowTasksTab, t)}
+
+
{selectedProject.displayName}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/MobileMenuButton.tsx b/src/components/main-content/view/subcomponents/MobileMenuButton.tsx
new file mode 100644
index 0000000..2333e4b
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/MobileMenuButton.tsx
@@ -0,0 +1,23 @@
+import type { MobileMenuButtonProps } from '../../types/types';
+import { useMobileMenuHandlers } from '../../hooks/useMobileMenuHandlers';
+
+export default function MobileMenuButton({ onMenuClick, compact = false }: MobileMenuButtonProps) {
+ const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick);
+
+ const buttonClasses = compact
+ ? 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button'
+ : 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0';
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx b/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
new file mode 100644
index 0000000..91810cf
--- /dev/null
+++ b/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
@@ -0,0 +1,206 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import TaskList from '../../../TaskList';
+import TaskDetail from '../../../TaskDetail';
+import PRDEditor from '../../../PRDEditor';
+import { useTaskMaster } from '../../../../contexts/TaskMasterContext';
+import { api } from '../../../../utils/api';
+import type { Project } from '../../../../types/app';
+import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from '../../types/types';
+
+const AnyTaskList = TaskList as any;
+const AnyTaskDetail = TaskDetail as any;
+const AnyPRDEditor = PRDEditor as any;
+
+type TaskMasterContextValue = {
+ tasks?: TaskMasterTask[];
+ currentProject?: Project | null;
+ refreshTasks?: (() => void) | null;
+};
+
+type PrdListResponse = {
+ prdFiles?: PrdFile[];
+ prds?: PrdFile[];
+};
+
+const PRD_SAVED_MESSAGE = 'PRD saved successfully!';
+
+function getPrdFiles(data: PrdListResponse): PrdFile[] {
+ return data.prdFiles || data.prds || [];
+}
+
+export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
+ const { tasks = [], currentProject, refreshTasks } = useTaskMaster() as TaskMasterContextValue;
+
+ const [selectedTask, setSelectedTask] = useState(null);
+ const [showTaskDetail, setShowTaskDetail] = useState(false);
+
+ const [showPRDEditor, setShowPRDEditor] = useState(false);
+ const [selectedPRD, setSelectedPRD] = useState(null);
+ const [existingPRDs, setExistingPRDs] = useState([]);
+ const [prdNotification, setPRDNotification] = useState(null);
+
+ const prdNotificationTimeoutRef = useRef | null>(null);
+
+ const showPrdNotification = useCallback((message: string) => {
+ if (prdNotificationTimeoutRef.current) {
+ clearTimeout(prdNotificationTimeoutRef.current);
+ }
+
+ setPRDNotification(message);
+ prdNotificationTimeoutRef.current = setTimeout(() => {
+ setPRDNotification(null);
+ prdNotificationTimeoutRef.current = null;
+ }, 3000);
+ }, []);
+
+ const loadExistingPrds = useCallback(async () => {
+ if (!currentProject?.name) {
+ setExistingPRDs([]);
+ return;
+ }
+
+ try {
+ const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
+ if (!response.ok) {
+ setExistingPRDs([]);
+ return;
+ }
+
+ const data = (await response.json()) as PrdListResponse;
+ setExistingPRDs(getPrdFiles(data));
+ } catch (error) {
+ console.error('Failed to load existing PRDs:', error);
+ setExistingPRDs([]);
+ }
+ }, [currentProject?.name]);
+
+ const refreshPrds = useCallback(
+ async (showNotification = false) => {
+ await loadExistingPrds();
+
+ if (showNotification) {
+ showPrdNotification(PRD_SAVED_MESSAGE);
+ }
+ },
+ [loadExistingPrds, showPrdNotification],
+ );
+
+ useEffect(() => {
+ void loadExistingPrds();
+ }, [loadExistingPrds]);
+
+ useEffect(() => {
+ return () => {
+ if (prdNotificationTimeoutRef.current) {
+ clearTimeout(prdNotificationTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleTaskClick = useCallback(
+ (task: TaskSelection) => {
+ if (!task || typeof task !== 'object' || !('id' in task)) {
+ return;
+ }
+
+ if (!('title' in task) || !task.title) {
+ const fullTask = tasks.find((candidate) => String(candidate.id) === String(task.id));
+ if (fullTask) {
+ setSelectedTask(fullTask);
+ setShowTaskDetail(true);
+ }
+ return;
+ }
+
+ setSelectedTask(task as TaskMasterTask);
+ setShowTaskDetail(true);
+ },
+ [tasks],
+ );
+
+ const handleTaskDetailClose = useCallback(() => {
+ setShowTaskDetail(false);
+ setSelectedTask(null);
+ }, []);
+
+ const handleTaskStatusChange = useCallback(
+ (taskId: string | number, newStatus: string) => {
+ console.log('Update task status:', taskId, newStatus);
+ refreshTasks?.();
+ },
+ [refreshTasks],
+ );
+
+ const handleOpenPrdEditor = useCallback((prd: PrdFile | null = null) => {
+ setSelectedPRD(prd);
+ setShowPRDEditor(true);
+ }, []);
+
+ const handleClosePrdEditor = useCallback(() => {
+ setShowPRDEditor(false);
+ setSelectedPRD(null);
+ }, []);
+
+ const handlePrdSave = useCallback(async () => {
+ handleClosePrdEditor();
+ await refreshPrds(true);
+ refreshTasks?.();
+ }, [handleClosePrdEditor, refreshPrds, refreshTasks]);
+
+ return (
+ <>
+
+
+
{
+ void refreshPrds(showNotification);
+ }}
+ />
+
+
+
+ {showTaskDetail && selectedTask && (
+
+ )}
+
+ {showPRDEditor && (
+
+ )}
+
+ {prdNotification && (
+
+
+
+
+
+
{prdNotification}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/modals/VersionUpgradeModal.tsx b/src/components/modals/VersionUpgradeModal.tsx
new file mode 100644
index 0000000..4d09dcd
--- /dev/null
+++ b/src/components/modals/VersionUpgradeModal.tsx
@@ -0,0 +1,219 @@
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { authenticatedFetch } from "../../utils/api";
+import { ReleaseInfo } from "../../types/sharedTypes";
+
+interface VersionUpgradeModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ releaseInfo: ReleaseInfo | null;
+ currentVersion: string;
+ latestVersion: string | null;
+}
+
+export default function VersionUpgradeModal({
+ isOpen,
+ onClose,
+ releaseInfo,
+ currentVersion,
+ latestVersion
+}: VersionUpgradeModalProps) {
+ const { t } = useTranslation('common');
+ const [isUpdating, setIsUpdating] = useState(false);
+ const [updateOutput, setUpdateOutput] = useState('');
+ const [updateError, setUpdateError] = useState('');
+
+ const handleUpdateNow = useCallback(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: any) {
+ setUpdateError(error.message);
+ setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
+ } finally {
+ setIsUpdating(false);
+ }
+ }, []);
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Header */}
+
+
+
+
+
{t('versionUpdate.title')}
+
+ {releaseInfo?.title || t('versionUpdate.newVersionReady')}
+
+
+
+
+
+
+
+
+
+
+ {/* Version Info */}
+
+
+ {t('versionUpdate.currentVersion')}
+ {currentVersion}
+
+
+ {t('versionUpdate.latestVersion')}
+ {latestVersion}
+
+
+
+ {/* Changelog */}
+ {releaseInfo?.body && (
+
+
+
+
+ {cleanChangelog(releaseInfo.body)}
+
+
+
+ )}
+
+ {/* Update Output */}
+ {(updateOutput || updateError) && (
+
+
{t('versionUpdate.updateProgress')}
+
+ {updateError && (
+
+ {updateError}
+
+ )}
+
+ )}
+
+ {/* Upgrade Instructions */}
+ {!isUpdating && !updateOutput && (
+
+
{t('versionUpdate.manualUpgrade')}
+
+
+ git checkout main && git pull && npm install
+
+
+
+ {t('versionUpdate.manualUpgradeHint')}
+
+
+ )}
+
+ {/* Actions */}
+
+
+ {updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
+
+ {!updateOutput && (
+ <>
+
{
+ navigator.clipboard.writeText('git checkout main && git pull && npm install');
+ }}
+ className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
+ >
+ {t('versionUpdate.buttons.copyCommand')}
+
+
+ {isUpdating ? (
+ <>
+
+ {t('versionUpdate.buttons.updating')}
+ >
+ ) : (
+ t('versionUpdate.buttons.updateNow')
+ )}
+
+ >
+ )}
+
+
+
+ );
+};
+
+// Clean up changelog by removing GitHub-specific metadata
+const cleanChangelog = (body: string) => {
+ 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();
+};
diff --git a/src/components/settings/AccountContent.jsx b/src/components/settings/AccountContent.jsx
index e9b2d3f..cad2e2a 100644
--- a/src/components/settings/AccountContent.jsx
+++ b/src/components/settings/AccountContent.jsx
@@ -1,16 +1,13 @@
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { LogIn } from 'lucide-react';
-import ClaudeLogo from '../ClaudeLogo';
-import CursorLogo from '../CursorLogo';
-import CodexLogo from '../CodexLogo';
+import SessionProviderLogo from '../SessionProviderLogo';
import { useTranslation } from 'react-i18next';
const agentConfig = {
claude: {
name: 'Claude',
description: 'Anthropic Claude AI assistant',
- Logo: ClaudeLogo,
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
borderClass: 'border-blue-200 dark:border-blue-800',
textClass: 'text-blue-900 dark:text-blue-100',
@@ -20,7 +17,6 @@ const agentConfig = {
cursor: {
name: 'Cursor',
description: 'Cursor AI-powered code editor',
- Logo: CursorLogo,
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
borderClass: 'border-purple-200 dark:border-purple-800',
textClass: 'text-purple-900 dark:text-purple-100',
@@ -30,7 +26,6 @@ const agentConfig = {
codex: {
name: 'Codex',
description: 'OpenAI Codex AI assistant',
- Logo: CodexLogo,
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
borderClass: 'border-gray-300 dark:border-gray-600',
textClass: 'text-gray-900 dark:text-gray-100',
@@ -42,12 +37,11 @@ const agentConfig = {
export default function AccountContent({ agent, authStatus, onLogin }) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
- const { Logo } = config;
return (
-
+
{config.name}
{t(`agents.account.${agent}.description`)}
diff --git a/src/components/settings/AgentListItem.jsx b/src/components/settings/AgentListItem.jsx
index 06e7477..babd8b5 100644
--- a/src/components/settings/AgentListItem.jsx
+++ b/src/components/settings/AgentListItem.jsx
@@ -1,23 +1,18 @@
-import ClaudeLogo from '../ClaudeLogo';
-import CursorLogo from '../CursorLogo';
-import CodexLogo from '../CodexLogo';
+import SessionProviderLogo from '../SessionProviderLogo';
import { useTranslation } from 'react-i18next';
const agentConfig = {
claude: {
name: 'Claude',
color: 'blue',
- Logo: ClaudeLogo,
},
cursor: {
name: 'Cursor',
color: 'purple',
- Logo: CursorLogo,
},
codex: {
name: 'Codex',
color: 'gray',
- Logo: CodexLogo,
},
};
@@ -46,7 +41,6 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
const { t } = useTranslation('settings');
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
- const { Logo } = config;
// Mobile: horizontal layout with bottom border
if (isMobile) {
@@ -60,7 +54,7 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
}`}
>
-
+
{config.name}
{authStatus?.authenticated && (
@@ -81,7 +75,7 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
}`}
>
-
+
{config.name}
diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts
new file mode 100644
index 0000000..03e26ca
--- /dev/null
+++ b/src/components/sidebar/hooks/useSidebarController.ts
@@ -0,0 +1,469 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type React from 'react';
+import type { TFunction } from 'i18next';
+import { api } from '../../../utils/api';
+import type { Project, ProjectSession } from '../../../types/app';
+import type {
+ AdditionalSessionsByProject,
+ DeleteProjectConfirmation,
+ LoadingSessionsByProject,
+ ProjectSortOrder,
+ SessionDeleteConfirmation,
+ SessionWithProvider,
+} from '../types/types';
+import {
+ filterProjects,
+ getAllSessions,
+ loadStarredProjects,
+ persistStarredProjects,
+ readProjectSortOrder,
+ sortProjects,
+} from '../utils/utils';
+
+type UseSidebarControllerArgs = {
+ projects: Project[];
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ isLoading: boolean;
+ isMobile: boolean;
+ t: TFunction;
+ onRefresh: () => Promise
| void;
+ onProjectSelect: (project: Project) => void;
+ onSessionSelect: (session: ProjectSession) => void;
+ onSessionDelete?: (sessionId: string) => void;
+ onProjectDelete?: (projectName: string) => void;
+ setCurrentProject: (project: Project) => void;
+ setSidebarVisible: (visible: boolean) => void;
+ sidebarVisible: boolean;
+};
+
+export function useSidebarController({
+ projects,
+ selectedProject,
+ selectedSession,
+ isLoading,
+ isMobile,
+ t,
+ onRefresh,
+ onProjectSelect,
+ onSessionSelect,
+ onSessionDelete,
+ onProjectDelete,
+ setCurrentProject,
+ setSidebarVisible,
+ sidebarVisible,
+}: UseSidebarControllerArgs) {
+ const [expandedProjects, setExpandedProjects] = useState>(new Set());
+ const [editingProject, setEditingProject] = useState(null);
+ const [showNewProject, setShowNewProject] = useState(false);
+ const [editingName, setEditingName] = useState('');
+ const [loadingSessions, setLoadingSessions] = useState({});
+ const [additionalSessions, setAdditionalSessions] = useState({});
+ const [initialSessionsLoaded, setInitialSessionsLoaded] = useState>(new Set());
+ const [currentTime, setCurrentTime] = useState(new Date());
+ const [projectSortOrder, setProjectSortOrder] = useState('name');
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [projectHasMoreOverrides, setProjectHasMoreOverrides] = useState>({});
+ const [editingSession, setEditingSession] = useState(null);
+ const [editingSessionName, setEditingSessionName] = useState('');
+ const [searchFilter, setSearchFilter] = useState('');
+ const [deletingProjects, setDeletingProjects] = useState>(new Set());
+ const [deleteConfirmation, setDeleteConfirmation] = useState(null);
+ const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null);
+ const [showVersionModal, setShowVersionModal] = useState(false);
+ const [starredProjects, setStarredProjects] = useState>(() => loadStarredProjects());
+
+ const isSidebarCollapsed = !isMobile && !sidebarVisible;
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCurrentTime(new Date());
+ }, 60000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ setAdditionalSessions({});
+ setInitialSessionsLoaded(new Set());
+ setProjectHasMoreOverrides({});
+ }, [projects]);
+
+ useEffect(() => {
+ if (selectedSession && selectedProject) {
+ setExpandedProjects((prev) => {
+ if (prev.has(selectedProject.name)) {
+ return prev;
+ }
+ const next = new Set(prev);
+ next.add(selectedProject.name);
+ return next;
+ });
+ }
+ }, [selectedSession, selectedProject]);
+
+ useEffect(() => {
+ if (projects.length > 0 && !isLoading) {
+ const loadedProjects = new Set();
+ projects.forEach((project) => {
+ if (project.sessions && project.sessions.length >= 0) {
+ loadedProjects.add(project.name);
+ }
+ });
+ setInitialSessionsLoaded(loadedProjects);
+ }
+ }, [projects, isLoading]);
+
+ useEffect(() => {
+ const loadSortOrder = () => {
+ setProjectSortOrder(readProjectSortOrder());
+ };
+
+ loadSortOrder();
+
+ const handleStorageChange = (event: StorageEvent) => {
+ if (event.key === 'claude-settings') {
+ loadSortOrder();
+ }
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+
+ const interval = setInterval(() => {
+ if (document.hasFocus()) {
+ loadSortOrder();
+ }
+ }, 1000);
+
+ return () => {
+ window.removeEventListener('storage', handleStorageChange);
+ clearInterval(interval);
+ };
+ }, []);
+
+ const handleTouchClick = useCallback(
+ (callback: () => void) =>
+ (event: React.TouchEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ callback();
+ },
+ [],
+ );
+
+ const toggleProject = useCallback((projectName: string) => {
+ setExpandedProjects((prev) => {
+ const next = new Set();
+ if (!prev.has(projectName)) {
+ next.add(projectName);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleSessionClick = useCallback(
+ (session: SessionWithProvider, projectName: string) => {
+ onSessionSelect({ ...session, __projectName: projectName });
+ },
+ [onSessionSelect],
+ );
+
+ const toggleStarProject = useCallback((projectName: string) => {
+ setStarredProjects((prev) => {
+ const next = new Set(prev);
+ if (next.has(projectName)) {
+ next.delete(projectName);
+ } else {
+ next.add(projectName);
+ }
+
+ persistStarredProjects(next);
+ return next;
+ });
+ }, []);
+
+ const isProjectStarred = useCallback(
+ (projectName: string) => starredProjects.has(projectName),
+ [starredProjects],
+ );
+
+ const getProjectSessions = useCallback(
+ (project: Project) => getAllSessions(project, additionalSessions),
+ [additionalSessions],
+ );
+
+ const projectsWithSessionMeta = useMemo(
+ () =>
+ projects.map((project) => {
+ const hasMoreOverride = projectHasMoreOverrides[project.name];
+ if (hasMoreOverride === undefined) {
+ return project;
+ }
+
+ return {
+ ...project,
+ sessionMeta: { ...project.sessionMeta, hasMore: hasMoreOverride },
+ };
+ }),
+ [projectHasMoreOverrides, projects],
+ );
+
+ const sortedProjects = useMemo(
+ () => sortProjects(projectsWithSessionMeta, projectSortOrder, starredProjects, additionalSessions),
+ [additionalSessions, projectSortOrder, projectsWithSessionMeta, starredProjects],
+ );
+
+ const filteredProjects = useMemo(
+ () => filterProjects(sortedProjects, searchFilter),
+ [searchFilter, sortedProjects],
+ );
+
+ const startEditing = useCallback((project: Project) => {
+ setEditingProject(project.name);
+ setEditingName(project.displayName);
+ }, []);
+
+ const cancelEditing = useCallback(() => {
+ setEditingProject(null);
+ setEditingName('');
+ }, []);
+
+ const saveProjectName = useCallback(
+ async (projectName: string) => {
+ try {
+ const response = await api.renameProject(projectName, editingName);
+ if (response.ok) {
+ if (window.refreshProjects) {
+ await window.refreshProjects();
+ } else {
+ window.location.reload();
+ }
+ } else {
+ console.error('Failed to rename project');
+ }
+ } catch (error) {
+ console.error('Error renaming project:', error);
+ } finally {
+ setEditingProject(null);
+ setEditingName('');
+ }
+ },
+ [editingName],
+ );
+
+ const showDeleteSessionConfirmation = useCallback(
+ (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionDeleteConfirmation['provider'] = 'claude',
+ ) => {
+ setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
+ },
+ [],
+ );
+
+ const confirmDeleteSession = useCallback(async () => {
+ if (!sessionDeleteConfirmation) {
+ return;
+ }
+
+ const { projectName, sessionId, provider } = sessionDeleteConfirmation;
+ setSessionDeleteConfirmation(null);
+
+ try {
+ const response =
+ provider === 'codex'
+ ? await api.deleteCodexSession(sessionId)
+ : await api.deleteSession(projectName, sessionId);
+
+ if (response.ok) {
+ onSessionDelete?.(sessionId);
+ } else {
+ const errorText = await response.text();
+ console.error('[Sidebar] Failed to delete session:', {
+ status: response.status,
+ error: errorText,
+ });
+ alert(t('messages.deleteSessionFailed'));
+ }
+ } catch (error) {
+ console.error('[Sidebar] Error deleting session:', error);
+ alert(t('messages.deleteSessionError'));
+ }
+ }, [onSessionDelete, sessionDeleteConfirmation, t]);
+
+ const requestProjectDelete = useCallback(
+ (project: Project) => {
+ setDeleteConfirmation({
+ project,
+ sessionCount: getProjectSessions(project).length,
+ });
+ },
+ [getProjectSessions],
+ );
+
+ const confirmDeleteProject = useCallback(async () => {
+ if (!deleteConfirmation) {
+ return;
+ }
+
+ const { project, sessionCount } = deleteConfirmation;
+ const isEmpty = sessionCount === 0;
+
+ setDeleteConfirmation(null);
+ setDeletingProjects((prev) => new Set([...prev, project.name]));
+
+ try {
+ const response = await api.deleteProject(project.name, !isEmpty);
+
+ if (response.ok) {
+ onProjectDelete?.(project.name);
+ } else {
+ const error = (await response.json()) as { error?: string };
+ alert(error.error || t('messages.deleteProjectFailed'));
+ }
+ } catch (error) {
+ console.error('Error deleting project:', error);
+ alert(t('messages.deleteProjectError'));
+ } finally {
+ setDeletingProjects((prev) => {
+ const next = new Set(prev);
+ next.delete(project.name);
+ return next;
+ });
+ }
+ }, [deleteConfirmation, onProjectDelete, t]);
+
+ const loadMoreSessions = useCallback(
+ async (project: Project) => {
+ const hasMoreOverride = projectHasMoreOverrides[project.name];
+ const canLoadMore =
+ hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true;
+ if (!canLoadMore || loadingSessions[project.name]) {
+ return;
+ }
+
+ setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));
+
+ try {
+ const currentSessionCount =
+ (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
+ const response = await api.sessions(project.name, 5, currentSessionCount);
+
+ if (!response.ok) {
+ return;
+ }
+
+ const result = (await response.json()) as {
+ sessions?: ProjectSession[];
+ hasMore?: boolean;
+ };
+
+ setAdditionalSessions((prev) => ({
+ ...prev,
+ [project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
+ }));
+
+ if (result.hasMore === false) {
+ // Keep hasMore state in local hook state instead of mutating the project prop object.
+ setProjectHasMoreOverrides((prev) => ({ ...prev, [project.name]: false }));
+ }
+ } catch (error) {
+ console.error('Error loading more sessions:', error);
+ } finally {
+ setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
+ }
+ },
+ [additionalSessions, loadingSessions, projectHasMoreOverrides],
+ );
+
+ const handleProjectSelect = useCallback(
+ (project: Project) => {
+ onProjectSelect(project);
+ setCurrentProject(project);
+ },
+ [onProjectSelect, setCurrentProject],
+ );
+
+ const refreshProjects = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [onRefresh]);
+
+ const updateSessionSummary = useCallback(
+ async (_projectName: string, _sessionId: string, _summary: string) => {
+ // Session rename endpoint is not currently exposed on the API.
+ setEditingSession(null);
+ setEditingSessionName('');
+ },
+ [],
+ );
+
+ const collapseSidebar = useCallback(() => {
+ setSidebarVisible(false);
+ }, [setSidebarVisible]);
+
+ const expandSidebar = useCallback(() => {
+ setSidebarVisible(true);
+ }, [setSidebarVisible]);
+
+ return {
+ isSidebarCollapsed,
+ expandedProjects,
+ editingProject,
+ showNewProject,
+ editingName,
+ loadingSessions,
+ additionalSessions,
+ initialSessionsLoaded,
+ currentTime,
+ projectSortOrder,
+ isRefreshing,
+ editingSession,
+ editingSessionName,
+ searchFilter,
+ deletingProjects,
+ deleteConfirmation,
+ sessionDeleteConfirmation,
+ showVersionModal,
+ starredProjects,
+ filteredProjects,
+ handleTouchClick,
+ toggleProject,
+ handleSessionClick,
+ toggleStarProject,
+ isProjectStarred,
+ getProjectSessions,
+ startEditing,
+ cancelEditing,
+ saveProjectName,
+ showDeleteSessionConfirmation,
+ confirmDeleteSession,
+ requestProjectDelete,
+ confirmDeleteProject,
+ loadMoreSessions,
+ handleProjectSelect,
+ refreshProjects,
+ updateSessionSummary,
+ collapseSidebar,
+ expandSidebar,
+ setShowNewProject,
+ setEditingName,
+ setEditingSession,
+ setEditingSessionName,
+ setSearchFilter,
+ setDeleteConfirmation,
+ setSessionDeleteConfirmation,
+ setShowVersionModal,
+ };
+}
diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts
new file mode 100644
index 0000000..b2fa98d
--- /dev/null
+++ b/src/components/sidebar/types/types.ts
@@ -0,0 +1,62 @@
+import type React from 'react';
+import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../types/app';
+
+export type ProjectSortOrder = 'name' | 'date';
+
+export type SessionWithProvider = ProjectSession & {
+ __provider: SessionProvider;
+};
+
+export type AdditionalSessionsByProject = Record;
+export type LoadingSessionsByProject = Record;
+
+export type DeleteProjectConfirmation = {
+ project: Project;
+ sessionCount: number;
+};
+
+export type SessionDeleteConfirmation = {
+ projectName: string;
+ sessionId: string;
+ sessionTitle: string;
+ provider: SessionProvider;
+};
+
+export type SidebarProps = {
+ projects: Project[];
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ onProjectSelect: (project: Project) => void;
+ onSessionSelect: (session: ProjectSession) => void;
+ onNewSession: (project: Project) => void;
+ onSessionDelete?: (sessionId: string) => void;
+ onProjectDelete?: (projectName: string) => void;
+ isLoading: boolean;
+ loadingProgress: LoadingProgress | null;
+ onRefresh: () => Promise | void;
+ onShowSettings: () => void;
+ showSettings: boolean;
+ settingsInitialTab: string;
+ onCloseSettings: () => void;
+ isMobile: boolean;
+};
+
+export type SessionViewModel = {
+ isCursorSession: boolean;
+ isCodexSession: boolean;
+ isActive: boolean;
+ sessionName: string;
+ sessionTime: string;
+ messageCount: number;
+};
+
+export type MCPServerStatus = {
+ hasMCPServer?: boolean;
+ isConfigured?: boolean;
+} | null;
+
+export type TouchHandlerFactory = (
+ callback: () => void,
+) => (event: React.TouchEvent) => void;
+
+export type SettingsProject = Pick;
diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts
new file mode 100644
index 0000000..62c9ce3
--- /dev/null
+++ b/src/components/sidebar/utils/utils.ts
@@ -0,0 +1,223 @@
+import type { TFunction } from 'i18next';
+import type { Project } from '../../../types/app';
+import type {
+ AdditionalSessionsByProject,
+ ProjectSortOrder,
+ SettingsProject,
+ SessionViewModel,
+ SessionWithProvider,
+} from '../types/types';
+
+export const readProjectSortOrder = (): ProjectSortOrder => {
+ try {
+ const rawSettings = localStorage.getItem('claude-settings');
+ if (!rawSettings) {
+ return 'name';
+ }
+
+ const settings = JSON.parse(rawSettings) as { projectSortOrder?: ProjectSortOrder };
+ return settings.projectSortOrder === 'date' ? 'date' : 'name';
+ } catch {
+ return 'name';
+ }
+};
+
+export const loadStarredProjects = (): Set => {
+ try {
+ const saved = localStorage.getItem('starredProjects');
+ return saved ? new Set(JSON.parse(saved)) : new Set();
+ } catch {
+ return new Set();
+ }
+};
+
+export const persistStarredProjects = (starredProjects: Set) => {
+ try {
+ localStorage.setItem('starredProjects', JSON.stringify([...starredProjects]));
+ } catch {
+ // Keep UI responsive even if storage fails.
+ }
+};
+
+export const getSessionDate = (session: SessionWithProvider): Date => {
+ if (session.__provider === 'cursor') {
+ return new Date(session.createdAt || 0);
+ }
+
+ if (session.__provider === 'codex') {
+ return new Date(session.createdAt || session.lastActivity || 0);
+ }
+
+ return new Date(session.lastActivity || 0);
+};
+
+export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
+ if (session.__provider === 'cursor') {
+ return session.name || t('projects.untitledSession');
+ }
+
+ if (session.__provider === 'codex') {
+ return session.summary || session.name || t('projects.codexSession');
+ }
+
+ return session.summary || t('projects.newSession');
+};
+
+export const getSessionTime = (session: SessionWithProvider): string => {
+ if (session.__provider === 'cursor') {
+ return String(session.createdAt || '');
+ }
+
+ if (session.__provider === 'codex') {
+ return String(session.createdAt || session.lastActivity || '');
+ }
+
+ return String(session.lastActivity || '');
+};
+
+export const createSessionViewModel = (
+ session: SessionWithProvider,
+ currentTime: Date,
+ t: TFunction,
+): SessionViewModel => {
+ const sessionDate = getSessionDate(session);
+ const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
+
+ return {
+ isCursorSession: session.__provider === 'cursor',
+ isCodexSession: session.__provider === 'codex',
+ isActive: diffInMinutes < 10,
+ sessionName: getSessionName(session, t),
+ sessionTime: getSessionTime(session),
+ messageCount: Number(session.messageCount || 0),
+ };
+};
+
+export const getAllSessions = (
+ project: Project,
+ additionalSessions: AdditionalSessionsByProject,
+): SessionWithProvider[] => {
+ const claudeSessions = [
+ ...(project.sessions || []),
+ ...(additionalSessions[project.name] || []),
+ ].map((session) => ({ ...session, __provider: 'claude' as const }));
+
+ const cursorSessions = (project.cursorSessions || []).map((session) => ({
+ ...session,
+ __provider: 'cursor' as const,
+ }));
+
+ const codexSessions = (project.codexSessions || []).map((session) => ({
+ ...session,
+ __provider: 'codex' as const,
+ }));
+
+ return [...claudeSessions, ...cursorSessions, ...codexSessions].sort(
+ (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
+ );
+};
+
+export const getProjectLastActivity = (
+ project: Project,
+ additionalSessions: AdditionalSessionsByProject,
+): Date => {
+ const sessions = getAllSessions(project, additionalSessions);
+ if (sessions.length === 0) {
+ return new Date(0);
+ }
+
+ return sessions.reduce((latest, session) => {
+ const sessionDate = getSessionDate(session);
+ return sessionDate > latest ? sessionDate : latest;
+ }, new Date(0));
+};
+
+export const sortProjects = (
+ projects: Project[],
+ projectSortOrder: ProjectSortOrder,
+ starredProjects: Set,
+ additionalSessions: AdditionalSessionsByProject,
+): Project[] => {
+ const byName = [...projects];
+
+ byName.sort((projectA, projectB) => {
+ const aStarred = starredProjects.has(projectA.name);
+ const bStarred = starredProjects.has(projectB.name);
+
+ if (aStarred && !bStarred) {
+ return -1;
+ }
+
+ if (!aStarred && bStarred) {
+ return 1;
+ }
+
+ if (projectSortOrder === 'date') {
+ return (
+ getProjectLastActivity(projectB, additionalSessions).getTime() -
+ getProjectLastActivity(projectA, additionalSessions).getTime()
+ );
+ }
+
+ return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);
+ });
+
+ return byName;
+};
+
+export const filterProjects = (projects: Project[], searchFilter: string): Project[] => {
+ const normalizedSearch = searchFilter.trim().toLowerCase();
+ if (!normalizedSearch) {
+ return projects;
+ }
+
+ return projects.filter((project) => {
+ const displayName = (project.displayName || project.name).toLowerCase();
+ const projectName = project.name.toLowerCase();
+ return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);
+ });
+};
+
+export const getTaskIndicatorStatus = (
+ project: Project,
+ mcpServerStatus: { hasMCPServer?: boolean; isConfigured?: boolean } | null,
+) => {
+ const projectConfigured = Boolean(project.taskmaster?.hasTaskmaster);
+ const mcpConfigured = Boolean(mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured);
+
+ if (projectConfigured && mcpConfigured) {
+ return 'fully-configured';
+ }
+
+ if (projectConfigured) {
+ return 'taskmaster-only';
+ }
+
+ if (mcpConfigured) {
+ return 'mcp-only';
+ }
+
+ return 'not-configured';
+};
+
+export const normalizeProjectForSettings = (project: Project): SettingsProject => {
+ const fallbackPath =
+ typeof project.fullPath === 'string' && project.fullPath.length > 0
+ ? project.fullPath
+ : typeof project.path === 'string'
+ ? project.path
+ : '';
+
+ return {
+ name: project.name,
+ displayName:
+ typeof project.displayName === 'string' && project.displayName.trim().length > 0
+ ? project.displayName
+ : project.name,
+ fullPath: fallbackPath,
+ path:
+ typeof project.path === 'string' && project.path.length > 0
+ ? project.path
+ : fallbackPath,
+ };
+};
diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx
new file mode 100644
index 0000000..d8633c9
--- /dev/null
+++ b/src/components/sidebar/view/Sidebar.tsx
@@ -0,0 +1,245 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
+import { useVersionCheck } from '../../../hooks/useVersionCheck';
+import { useUiPreferences } from '../../../hooks/useUiPreferences';
+import { useSidebarController } from '../hooks/useSidebarController';
+import { useTaskMaster } from '../../../contexts/TaskMasterContext';
+import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
+import SidebarCollapsed from './subcomponents/SidebarCollapsed';
+import SidebarContent from './subcomponents/SidebarContent';
+import SidebarModals from './subcomponents/SidebarModals';
+import type { Project } from '../../../types/app';
+import type { SidebarProjectListProps } from './subcomponents/SidebarProjectList';
+import type { MCPServerStatus, SidebarProps } from '../types/types';
+
+type TaskMasterSidebarContext = {
+ setCurrentProject: (project: Project) => void;
+ mcpServerStatus: MCPServerStatus;
+};
+
+function Sidebar({
+ projects,
+ selectedProject,
+ selectedSession,
+ onProjectSelect,
+ onSessionSelect,
+ onNewSession,
+ onSessionDelete,
+ onProjectDelete,
+ isLoading,
+ loadingProgress,
+ onRefresh,
+ onShowSettings,
+ showSettings,
+ settingsInitialTab,
+ onCloseSettings,
+ isMobile,
+}: SidebarProps) {
+ const { t } = useTranslation(['sidebar', 'common']);
+ const { isPWA } = useDeviceSettings({ trackMobile: false });
+ const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck(
+ 'siteboon',
+ 'claudecodeui',
+ );
+ const { preferences, setPreference } = useUiPreferences();
+ const { sidebarVisible } = preferences;
+ const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext;
+ const { tasksEnabled } = useTasksSettings();
+
+ const {
+ isSidebarCollapsed,
+ expandedProjects,
+ editingProject,
+ showNewProject,
+ editingName,
+ loadingSessions,
+ initialSessionsLoaded,
+ currentTime,
+ isRefreshing,
+ editingSession,
+ editingSessionName,
+ searchFilter,
+ deletingProjects,
+ deleteConfirmation,
+ sessionDeleteConfirmation,
+ showVersionModal,
+ filteredProjects,
+ handleTouchClick,
+ toggleProject,
+ handleSessionClick,
+ toggleStarProject,
+ isProjectStarred,
+ getProjectSessions,
+ startEditing,
+ cancelEditing,
+ saveProjectName,
+ showDeleteSessionConfirmation,
+ confirmDeleteSession,
+ requestProjectDelete,
+ confirmDeleteProject,
+ loadMoreSessions,
+ handleProjectSelect,
+ refreshProjects,
+ updateSessionSummary,
+ collapseSidebar: handleCollapseSidebar,
+ expandSidebar: handleExpandSidebar,
+ setShowNewProject,
+ setEditingName,
+ setEditingSession,
+ setEditingSessionName,
+ setSearchFilter,
+ setDeleteConfirmation,
+ setSessionDeleteConfirmation,
+ setShowVersionModal,
+ } = useSidebarController({
+ projects,
+ selectedProject,
+ selectedSession,
+ isLoading,
+ isMobile,
+ t,
+ onRefresh,
+ onProjectSelect,
+ onSessionSelect,
+ onSessionDelete,
+ onProjectDelete,
+ setCurrentProject,
+ setSidebarVisible: (visible) => setPreference('sidebarVisible', visible),
+ sidebarVisible,
+ });
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ document.documentElement.classList.toggle('pwa-mode', isPWA);
+ document.body.classList.toggle('pwa-mode', isPWA);
+ }, [isPWA]);
+
+ const handleProjectCreated = () => {
+ if (window.refreshProjects) {
+ void window.refreshProjects();
+ return;
+ }
+
+ window.location.reload();
+ };
+
+ const projectListProps: SidebarProjectListProps = {
+ projects,
+ filteredProjects,
+ selectedProject,
+ selectedSession,
+ isLoading,
+ loadingProgress,
+ expandedProjects,
+ editingProject,
+ editingName,
+ loadingSessions,
+ initialSessionsLoaded,
+ currentTime,
+ editingSession,
+ editingSessionName,
+ deletingProjects,
+ tasksEnabled,
+ mcpServerStatus,
+ getProjectSessions,
+ isProjectStarred,
+ onEditingNameChange: setEditingName,
+ onToggleProject: toggleProject,
+ onProjectSelect: handleProjectSelect,
+ onToggleStarProject: toggleStarProject,
+ onStartEditingProject: startEditing,
+ onCancelEditingProject: cancelEditing,
+ onSaveProjectName: (projectName) => {
+ void saveProjectName(projectName);
+ },
+ onDeleteProject: requestProjectDelete,
+ onSessionSelect: handleSessionClick,
+ onDeleteSession: showDeleteSessionConfirmation,
+ onLoadMoreSessions: (project) => {
+ void loadMoreSessions(project);
+ },
+ onNewSession,
+ onEditingSessionNameChange: setEditingSessionName,
+ onStartEditingSession: (sessionId, initialName) => {
+ setEditingSession(sessionId);
+ setEditingSessionName(initialName);
+ },
+ onCancelEditingSession: () => {
+ setEditingSession(null);
+ setEditingSessionName('');
+ },
+ onSaveEditingSession: (projectName, sessionId, summary) => {
+ void updateSessionSummary(projectName, sessionId, summary);
+ },
+ touchHandlerFactory: handleTouchClick,
+ t,
+ };
+
+ return (
+ <>
+ setShowNewProject(false)}
+ onProjectCreated={handleProjectCreated}
+ deleteConfirmation={deleteConfirmation}
+ onCancelDeleteProject={() => setDeleteConfirmation(null)}
+ onConfirmDeleteProject={confirmDeleteProject}
+ sessionDeleteConfirmation={sessionDeleteConfirmation}
+ onCancelDeleteSession={() => setSessionDeleteConfirmation(null)}
+ onConfirmDeleteSession={confirmDeleteSession}
+ showVersionModal={showVersionModal}
+ onCloseVersionModal={() => setShowVersionModal(false)}
+ releaseInfo={releaseInfo}
+ currentVersion={currentVersion}
+ latestVersion={latestVersion}
+ t={t}
+ />
+
+ {isSidebarCollapsed ? (
+ setShowVersionModal(true)}
+ t={t}
+ />
+ ) : (
+ <>
+ setSearchFilter('')}
+ onRefresh={() => {
+ void refreshProjects();
+ }}
+ isRefreshing={isRefreshing}
+ onCreateProject={() => setShowNewProject(true)}
+ onCollapseSidebar={handleCollapseSidebar}
+ updateAvailable={updateAvailable}
+ releaseInfo={releaseInfo}
+ latestVersion={latestVersion}
+ onShowVersionModal={() => setShowVersionModal(true)}
+ onShowSettings={onShowSettings}
+ projectListProps={projectListProps}
+ t={t}
+ />
+ >
+ )}
+
+ >
+ );
+}
+
+export default Sidebar;
diff --git a/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx b/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx
new file mode 100644
index 0000000..a98756c
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx
@@ -0,0 +1,59 @@
+import { Settings, Sparkles } from 'lucide-react';
+import type { TFunction } from 'i18next';
+
+type SidebarCollapsedProps = {
+ onExpand: () => void;
+ onShowSettings: () => void;
+ updateAvailable: boolean;
+ onShowVersionModal: () => void;
+ t: TFunction;
+};
+
+export default function SidebarCollapsed({
+ onExpand,
+ onShowSettings,
+ updateAvailable,
+ onShowVersionModal,
+ t,
+}: SidebarCollapsedProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {updateAvailable && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx
new file mode 100644
index 0000000..37b9874
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx
@@ -0,0 +1,84 @@
+import { ScrollArea } from '../../../ui/scroll-area';
+import type { TFunction } from 'i18next';
+import type { Project } from '../../../../types/app';
+import type { ReleaseInfo } from '../../../../types/sharedTypes';
+import SidebarFooter from './SidebarFooter';
+import SidebarHeader from './SidebarHeader';
+import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
+
+type SidebarContentProps = {
+ isPWA: boolean;
+ isMobile: boolean;
+ isLoading: boolean;
+ projects: Project[];
+ searchFilter: string;
+ onSearchFilterChange: (value: string) => void;
+ onClearSearchFilter: () => void;
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ onCreateProject: () => void;
+ onCollapseSidebar: () => void;
+ updateAvailable: boolean;
+ releaseInfo: ReleaseInfo | null;
+ latestVersion: string | null;
+ onShowVersionModal: () => void;
+ onShowSettings: () => void;
+ projectListProps: SidebarProjectListProps;
+ t: TFunction;
+};
+
+export default function SidebarContent({
+ isPWA,
+ isMobile,
+ isLoading,
+ projects,
+ searchFilter,
+ onSearchFilterChange,
+ onClearSearchFilter,
+ onRefresh,
+ isRefreshing,
+ onCreateProject,
+ onCollapseSidebar,
+ updateAvailable,
+ releaseInfo,
+ latestVersion,
+ onShowVersionModal,
+ onShowSettings,
+ projectListProps,
+ t,
+}: SidebarContentProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarFooter.tsx b/src/components/sidebar/view/subcomponents/SidebarFooter.tsx
new file mode 100644
index 0000000..de9dcbc
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarFooter.tsx
@@ -0,0 +1,94 @@
+import { Settings } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { ReleaseInfo } from '../../../../types/sharedTypes';
+import { Button } from '../../../ui/button';
+
+type SidebarFooterProps = {
+ updateAvailable: boolean;
+ releaseInfo: ReleaseInfo | null;
+ latestVersion: string | null;
+ onShowVersionModal: () => void;
+ onShowSettings: () => void;
+ t: TFunction;
+};
+
+export default function SidebarFooter({
+ updateAvailable,
+ releaseInfo,
+ latestVersion,
+ onShowVersionModal,
+ onShowSettings,
+ t,
+}: SidebarFooterProps) {
+ return (
+ <>
+ {updateAvailable && (
+
+
+
+
+
+
+ {releaseInfo?.title || `Version ${latestVersion}`}
+
+
{t('version.updateAvailable')}
+
+
+
+
+
+
+
+
+
+ {releaseInfo?.title || `Version ${latestVersion}`}
+
+
{t('version.updateAvailable')}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {t('actions.settings')}
+
+
+
+
+
+ {t('actions.settings')}
+
+
+ >
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx
new file mode 100644
index 0000000..4b0ea22
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx
@@ -0,0 +1,187 @@
+import { FolderPlus, MessageSquare, RefreshCw, Search, X } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { Button } from '../../../ui/button';
+import { Input } from '../../../ui/input';
+import { IS_PLATFORM } from '../../../../constants/config';
+
+type SidebarHeaderProps = {
+ isPWA: boolean;
+ isMobile: boolean;
+ isLoading: boolean;
+ projectsCount: number;
+ searchFilter: string;
+ onSearchFilterChange: (value: string) => void;
+ onClearSearchFilter: () => void;
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ onCreateProject: () => void;
+ onCollapseSidebar: () => void;
+ t: TFunction;
+};
+
+export default function SidebarHeader({
+ isPWA,
+ isMobile,
+ isLoading,
+ projectsCount,
+ searchFilter,
+ onSearchFilterChange,
+ onClearSearchFilter,
+ onRefresh,
+ isRefreshing,
+ onCreateProject,
+ onCollapseSidebar,
+ t,
+}: SidebarHeaderProps) {
+ return (
+ <>
+
+
+ {!isLoading && !isMobile && (
+
+
+
+
+ {t('projects.newProject')}
+
+
+
+
+
+
+ )}
+
+ {projectsCount > 0 && !isLoading && (
+
+
+
+ onSearchFilterChange(event.target.value)}
+ className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
+ />
+ {searchFilter && (
+
+
+
+ )}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx
new file mode 100644
index 0000000..312f389
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx
@@ -0,0 +1,201 @@
+import { useMemo } from 'react';
+import ReactDOM from 'react-dom';
+import { AlertTriangle, Trash2 } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { Button } from '../../../ui/button';
+import ProjectCreationWizard from '../../../ProjectCreationWizard';
+import Settings from '../../../Settings';
+import VersionUpgradeModal from '../../../modals/VersionUpgradeModal';
+import type { Project } from '../../../../types/app';
+import type { ReleaseInfo } from '../../../../types/sharedTypes';
+import { normalizeProjectForSettings } from '../../utils/utils';
+import type { DeleteProjectConfirmation, SessionDeleteConfirmation, SettingsProject } from '../../types/types';
+
+type SidebarModalsProps = {
+ projects: Project[];
+ showSettings: boolean;
+ settingsInitialTab: string;
+ onCloseSettings: () => void;
+ showNewProject: boolean;
+ onCloseNewProject: () => void;
+ onProjectCreated: () => void;
+ deleteConfirmation: DeleteProjectConfirmation | null;
+ onCancelDeleteProject: () => void;
+ onConfirmDeleteProject: () => void;
+ sessionDeleteConfirmation: SessionDeleteConfirmation | null;
+ onCancelDeleteSession: () => void;
+ onConfirmDeleteSession: () => void;
+ showVersionModal: boolean;
+ onCloseVersionModal: () => void;
+ releaseInfo: ReleaseInfo | null;
+ currentVersion: string;
+ latestVersion: string | null;
+ t: TFunction;
+};
+
+type TypedSettingsProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ projects: SettingsProject[];
+ initialTab: string;
+};
+
+const SettingsComponent = Settings as (props: TypedSettingsProps) => JSX.Element;
+
+function TypedSettings(props: TypedSettingsProps) {
+ return ;
+}
+
+export default function SidebarModals({
+ projects,
+ showSettings,
+ settingsInitialTab,
+ onCloseSettings,
+ showNewProject,
+ onCloseNewProject,
+ onProjectCreated,
+ deleteConfirmation,
+ onCancelDeleteProject,
+ onConfirmDeleteProject,
+ sessionDeleteConfirmation,
+ onCancelDeleteSession,
+ onConfirmDeleteSession,
+ showVersionModal,
+ onCloseVersionModal,
+ releaseInfo,
+ currentVersion,
+ latestVersion,
+ t,
+}: SidebarModalsProps) {
+ // Settings expects project identity/path fields to be present for dropdown labels and local-scope MCP config.
+ const settingsProjects = useMemo(
+ () => projects.map(normalizeProjectForSettings),
+ [projects],
+ );
+
+ return (
+ <>
+ {showNewProject &&
+ ReactDOM.createPortal(
+ ,
+ document.body,
+ )}
+
+
+
+ {deleteConfirmation &&
+ ReactDOM.createPortal(
+
+
+
+
+
+
+
+ {t('deleteConfirmation.deleteProject')}
+
+
+ {t('deleteConfirmation.confirmDelete')}{' '}
+
+ {deleteConfirmation.project.displayName || deleteConfirmation.project.name}
+
+ ?
+
+ {deleteConfirmation.sessionCount > 0 && (
+
+
+ {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
+
+
+ {t('deleteConfirmation.allConversationsDeleted')}
+
+
+ )}
+
+ {t('deleteConfirmation.cannotUndo')}
+
+
+
+
+
+
+ {t('actions.cancel')}
+
+
+
+ {t('actions.delete')}
+
+
+
+
,
+ document.body,
+ )}
+
+ {sessionDeleteConfirmation &&
+ ReactDOM.createPortal(
+
+
+
+
+
+
+
+ {t('deleteConfirmation.deleteSession')}
+
+
+ {t('deleteConfirmation.confirmDelete')}{' '}
+
+ {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
+
+ ?
+
+
+ {t('deleteConfirmation.cannotUndo')}
+
+
+
+
+
+
+ {t('actions.cancel')}
+
+
+
+ {t('actions.delete')}
+
+
+
+
,
+ document.body,
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
new file mode 100644
index 0000000..4d28b3b
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
@@ -0,0 +1,432 @@
+import { Button } from '../../../ui/button';
+import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { cn } from '../../../../lib/utils';
+import TaskIndicator from '../../../TaskIndicator';
+import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
+import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from '../../types/types';
+import { getTaskIndicatorStatus } from '../../utils/utils';
+import SidebarProjectSessions from './SidebarProjectSessions';
+
+type SidebarProjectItemProps = {
+ project: Project;
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ isExpanded: boolean;
+ isDeleting: boolean;
+ isStarred: boolean;
+ editingProject: string | null;
+ editingName: string;
+ sessions: SessionWithProvider[];
+ initialSessionsLoaded: boolean;
+ isLoadingSessions: boolean;
+ currentTime: Date;
+ editingSession: string | null;
+ editingSessionName: string;
+ tasksEnabled: boolean;
+ mcpServerStatus: MCPServerStatus;
+ onEditingNameChange: (name: string) => void;
+ onToggleProject: (projectName: string) => void;
+ onProjectSelect: (project: Project) => void;
+ onToggleStarProject: (projectName: string) => void;
+ onStartEditingProject: (project: Project) => void;
+ onCancelEditingProject: () => void;
+ onSaveProjectName: (projectName: string) => void;
+ onDeleteProject: (project: Project) => void;
+ onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
+ onDeleteSession: (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionProvider,
+ ) => void;
+ onLoadMoreSessions: (project: Project) => void;
+ onNewSession: (project: Project) => void;
+ onEditingSessionNameChange: (value: string) => void;
+ onStartEditingSession: (sessionId: string, initialName: string) => void;
+ onCancelEditingSession: () => void;
+ onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
+ touchHandlerFactory: TouchHandlerFactory;
+ t: TFunction;
+};
+
+const getSessionCountDisplay = (sessions: SessionWithProvider[], hasMoreSessions: boolean): string => {
+ const sessionCount = sessions.length;
+ if (hasMoreSessions && sessionCount >= 5) {
+ return `${sessionCount}+`;
+ }
+
+ return `${sessionCount}`;
+};
+
+export default function SidebarProjectItem({
+ project,
+ selectedProject,
+ selectedSession,
+ isExpanded,
+ isDeleting,
+ isStarred,
+ editingProject,
+ editingName,
+ sessions,
+ initialSessionsLoaded,
+ isLoadingSessions,
+ currentTime,
+ editingSession,
+ editingSessionName,
+ tasksEnabled,
+ mcpServerStatus,
+ onEditingNameChange,
+ onToggleProject,
+ onProjectSelect,
+ onToggleStarProject,
+ onStartEditingProject,
+ onCancelEditingProject,
+ onSaveProjectName,
+ onDeleteProject,
+ onSessionSelect,
+ onDeleteSession,
+ onLoadMoreSessions,
+ onNewSession,
+ onEditingSessionNameChange,
+ onStartEditingSession,
+ onCancelEditingSession,
+ onSaveEditingSession,
+ touchHandlerFactory,
+ t,
+}: SidebarProjectItemProps) {
+ const isSelected = selectedProject?.name === project.name;
+ const isEditing = editingProject === project.name;
+ const hasMoreSessions = project.sessionMeta?.hasMore === true;
+ const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions);
+ const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;
+ const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
+
+ const toggleProject = () => onToggleProject(project.name);
+ const toggleStarProject = () => onToggleStarProject(project.name);
+
+ const saveProjectName = () => {
+ onSaveProjectName(project.name);
+ };
+
+ const selectAndToggleProject = () => {
+ if (selectedProject?.name !== project.name) {
+ onProjectSelect(project);
+ }
+
+ toggleProject();
+ };
+
+ return (
+
+
+
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isEditing ? (
+
onEditingNameChange(event.target.value)}
+ className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
+ placeholder={t('projects.projectNamePlaceholder')}
+ autoFocus
+ autoComplete="off"
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ saveProjectName();
+ }
+
+ if (event.key === 'Escape') {
+ onCancelEditingProject();
+ }
+ }}
+ style={{
+ fontSize: '16px',
+ WebkitAppearance: 'none',
+ borderRadius: '8px',
+ }}
+ />
+ ) : (
+ <>
+
+
{project.displayName}
+ {tasksEnabled && (
+
+ )}
+
+
{sessionCountLabel}
+ >
+ )}
+
+
+
+
+ {isEditing ? (
+ <>
+
{
+ event.stopPropagation();
+ saveProjectName();
+ }}
+ >
+
+
+
{
+ event.stopPropagation();
+ onCancelEditingProject();
+ }}
+ >
+
+
+ >
+ ) : (
+ <>
+
{
+ event.stopPropagation();
+ toggleStarProject();
+ }}
+ title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
+ >
+
+
+
+
{
+ event.stopPropagation();
+ onDeleteProject(project);
+ }}
+ >
+
+
+
+
{
+ event.stopPropagation();
+ onStartEditingProject(project);
+ }}
+ >
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {isEditing ? (
+
+
onEditingNameChange(event.target.value)}
+ className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
+ placeholder={t('projects.projectNamePlaceholder')}
+ autoFocus
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ saveProjectName();
+ }
+ if (event.key === 'Escape') {
+ onCancelEditingProject();
+ }
+ }}
+ />
+
+ {project.fullPath}
+
+
+ ) : (
+
+
+ {project.displayName}
+
+
+ {sessionCountDisplay}
+ {project.fullPath !== project.displayName && (
+
+ {' - '}
+ {project.fullPath.length > 25 ? `...${project.fullPath.slice(-22)}` : project.fullPath}
+
+ )}
+
+
+ )}
+
+
+
+
+ {isEditing ? (
+ <>
+
{
+ event.stopPropagation();
+ saveProjectName();
+ }}
+ >
+
+
+
{
+ event.stopPropagation();
+ onCancelEditingProject();
+ }}
+ >
+
+
+ >
+ ) : (
+ <>
+
{
+ event.stopPropagation();
+ toggleStarProject();
+ }}
+ title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
+ >
+
+
+
{
+ event.stopPropagation();
+ onStartEditingProject(project);
+ }}
+ title={t('tooltips.renameProject')}
+ >
+
+
+
{
+ event.stopPropagation();
+ onDeleteProject(project);
+ }}
+ title={t('tooltips.deleteProject')}
+ >
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx
new file mode 100644
index 0000000..5571063
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx
@@ -0,0 +1,153 @@
+import type { TFunction } from 'i18next';
+import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
+import type {
+ LoadingSessionsByProject,
+ MCPServerStatus,
+ SessionWithProvider,
+ TouchHandlerFactory,
+} from '../../types/types';
+import SidebarProjectItem from './SidebarProjectItem';
+import SidebarProjectsState from './SidebarProjectsState';
+
+export type SidebarProjectListProps = {
+ projects: Project[];
+ filteredProjects: Project[];
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ isLoading: boolean;
+ loadingProgress: LoadingProgress | null;
+ expandedProjects: Set;
+ editingProject: string | null;
+ editingName: string;
+ loadingSessions: LoadingSessionsByProject;
+ initialSessionsLoaded: Set;
+ currentTime: Date;
+ editingSession: string | null;
+ editingSessionName: string;
+ deletingProjects: Set;
+ tasksEnabled: boolean;
+ mcpServerStatus: MCPServerStatus;
+ getProjectSessions: (project: Project) => SessionWithProvider[];
+ isProjectStarred: (projectName: string) => boolean;
+ onEditingNameChange: (value: string) => void;
+ onToggleProject: (projectName: string) => void;
+ onProjectSelect: (project: Project) => void;
+ onToggleStarProject: (projectName: string) => void;
+ onStartEditingProject: (project: Project) => void;
+ onCancelEditingProject: () => void;
+ onSaveProjectName: (projectName: string) => void;
+ onDeleteProject: (project: Project) => void;
+ onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
+ onDeleteSession: (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionProvider,
+ ) => void;
+ onLoadMoreSessions: (project: Project) => void;
+ onNewSession: (project: Project) => void;
+ onEditingSessionNameChange: (value: string) => void;
+ onStartEditingSession: (sessionId: string, initialName: string) => void;
+ onCancelEditingSession: () => void;
+ onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
+ touchHandlerFactory: TouchHandlerFactory;
+ t: TFunction;
+};
+
+export default function SidebarProjectList({
+ projects,
+ filteredProjects,
+ selectedProject,
+ selectedSession,
+ isLoading,
+ loadingProgress,
+ expandedProjects,
+ editingProject,
+ editingName,
+ loadingSessions,
+ initialSessionsLoaded,
+ currentTime,
+ editingSession,
+ editingSessionName,
+ deletingProjects,
+ tasksEnabled,
+ mcpServerStatus,
+ getProjectSessions,
+ isProjectStarred,
+ onEditingNameChange,
+ onToggleProject,
+ onProjectSelect,
+ onToggleStarProject,
+ onStartEditingProject,
+ onCancelEditingProject,
+ onSaveProjectName,
+ onDeleteProject,
+ onSessionSelect,
+ onDeleteSession,
+ onLoadMoreSessions,
+ onNewSession,
+ onEditingSessionNameChange,
+ onStartEditingSession,
+ onCancelEditingSession,
+ onSaveEditingSession,
+ touchHandlerFactory,
+ t,
+}: SidebarProjectListProps) {
+ const state = (
+
+ );
+
+ const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;
+
+ return (
+
+ {!showProjects
+ ? state
+ : filteredProjects.map((project) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx
new file mode 100644
index 0000000..e4ff955
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx
@@ -0,0 +1,160 @@
+import { ChevronDown, Plus } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { Button } from '../../../ui/button';
+import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
+import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types';
+import SidebarSessionItem from './SidebarSessionItem';
+
+type SidebarProjectSessionsProps = {
+ project: Project;
+ isExpanded: boolean;
+ sessions: SessionWithProvider[];
+ selectedSession: ProjectSession | null;
+ initialSessionsLoaded: boolean;
+ isLoadingSessions: boolean;
+ currentTime: Date;
+ editingSession: string | null;
+ editingSessionName: string;
+ onEditingSessionNameChange: (value: string) => void;
+ onStartEditingSession: (sessionId: string, initialName: string) => void;
+ onCancelEditingSession: () => void;
+ onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
+ onProjectSelect: (project: Project) => void;
+ onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
+ onDeleteSession: (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionProvider,
+ ) => void;
+ onLoadMoreSessions: (project: Project) => void;
+ onNewSession: (project: Project) => void;
+ touchHandlerFactory: TouchHandlerFactory;
+ t: TFunction;
+};
+
+function SessionListSkeleton() {
+ return (
+ <>
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+ >
+ );
+}
+
+export default function SidebarProjectSessions({
+ project,
+ isExpanded,
+ sessions,
+ selectedSession,
+ initialSessionsLoaded,
+ isLoadingSessions,
+ currentTime,
+ editingSession,
+ editingSessionName,
+ onEditingSessionNameChange,
+ onStartEditingSession,
+ onCancelEditingSession,
+ onSaveEditingSession,
+ onProjectSelect,
+ onSessionSelect,
+ onDeleteSession,
+ onLoadMoreSessions,
+ onNewSession,
+ touchHandlerFactory,
+ t,
+}: SidebarProjectSessionsProps) {
+ if (!isExpanded) {
+ return null;
+ }
+
+ const hasSessions = sessions.length > 0;
+ const hasMoreSessions = project.sessionMeta?.hasMore === true;
+
+ return (
+
+ {!initialSessionsLoaded ? (
+
+ ) : !hasSessions && !isLoadingSessions ? (
+
+
{t('sessions.noSessions')}
+
+ ) : (
+ sessions.map((session) => (
+
+ ))
+ )}
+
+ {hasSessions && hasMoreSessions && (
+
onLoadMoreSessions(project)}
+ disabled={isLoadingSessions}
+ >
+ {isLoadingSessions ? (
+ <>
+
+ {t('sessions.loading')}
+ >
+ ) : (
+ <>
+
+ {t('sessions.showMore')}
+ >
+ )}
+
+ )}
+
+
+
{
+ onProjectSelect(project);
+ onNewSession(project);
+ }}
+ >
+
+ {t('sessions.newSession')}
+
+
+
+
onNewSession(project)}
+ >
+
+ {t('sessions.newSession')}
+
+
+ );
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectsState.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectsState.tsx
new file mode 100644
index 0000000..8271a3f
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarProjectsState.tsx
@@ -0,0 +1,79 @@
+import { Folder, Search } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { LoadingProgress } from '../../../../types/app';
+
+type SidebarProjectsStateProps = {
+ isLoading: boolean;
+ loadingProgress: LoadingProgress | null;
+ projectsCount: number;
+ filteredProjectsCount: number;
+ t: TFunction;
+};
+
+export default function SidebarProjectsState({
+ isLoading,
+ loadingProgress,
+ projectsCount,
+ filteredProjectsCount,
+ t,
+}: SidebarProjectsStateProps) {
+ if (isLoading) {
+ return (
+
+
+
{t('projects.loadingProjects')}
+ {loadingProgress && loadingProgress.total > 0 ? (
+
+
+
+ {loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
+
+ {loadingProgress.currentProject && (
+
+ {loadingProgress.currentProject.split('-').slice(-2).join('/')}
+
+ )}
+
+ ) : (
+
{t('projects.fetchingProjects')}
+ )}
+
+ );
+ }
+
+ if (projectsCount === 0) {
+ return (
+
+
+
+
+
{t('projects.noProjects')}
+
{t('projects.runClaudeCli')}
+
+ );
+ }
+
+ if (filteredProjectsCount === 0) {
+ return (
+
+
+
+
+
{t('projects.noMatchingProjects')}
+
{t('projects.tryDifferentSearch')}
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
new file mode 100644
index 0000000..eb62ddb
--- /dev/null
+++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
@@ -0,0 +1,236 @@
+import { Badge } from '../../../ui/badge';
+import { Button } from '../../../ui/button';
+import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { cn } from '../../../../lib/utils';
+import { formatTimeAgo } from '../../../../utils/dateUtils';
+import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
+import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types';
+import { createSessionViewModel } from '../../utils/utils';
+import SessionProviderLogo from '../../../SessionProviderLogo';
+
+type SidebarSessionItemProps = {
+ project: Project;
+ session: SessionWithProvider;
+ selectedSession: ProjectSession | null;
+ currentTime: Date;
+ editingSession: string | null;
+ editingSessionName: string;
+ onEditingSessionNameChange: (value: string) => void;
+ onStartEditingSession: (sessionId: string, initialName: string) => void;
+ onCancelEditingSession: () => void;
+ onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
+ onProjectSelect: (project: Project) => void;
+ onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
+ onDeleteSession: (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionProvider,
+ ) => void;
+ touchHandlerFactory: TouchHandlerFactory;
+ t: TFunction;
+};
+
+export default function SidebarSessionItem({
+ project,
+ session,
+ selectedSession,
+ currentTime,
+ editingSession,
+ editingSessionName,
+ onEditingSessionNameChange,
+ onStartEditingSession,
+ onCancelEditingSession,
+ onSaveEditingSession,
+ onProjectSelect,
+ onSessionSelect,
+ onDeleteSession,
+ touchHandlerFactory,
+ t,
+}: SidebarSessionItemProps) {
+ const sessionView = createSessionViewModel(session, currentTime, t);
+ const isSelected = selectedSession?.id === session.id;
+
+ const selectMobileSession = () => {
+ onProjectSelect(project);
+ onSessionSelect(session, project.name);
+ };
+
+ const saveEditedSession = () => {
+ onSaveEditingSession(project.name, session.id, editingSessionName);
+ };
+
+ const requestDeleteSession = () => {
+ onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider);
+ };
+
+ return (
+
+ {sessionView.isActive && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
{sessionView.sessionName}
+
+
+
+ {formatTimeAgo(sessionView.sessionTime, currentTime, t)}
+
+ {sessionView.messageCount > 0 && (
+
+ {sessionView.messageCount}
+
+ )}
+
+
+
+
+
+
+ {!sessionView.isCursorSession && (
+
{
+ event.stopPropagation();
+ requestDeleteSession();
+ }}
+ >
+
+
+ )}
+
+
+
+
+
+
onSessionSelect(session, project.name)}
+ >
+
+
+
+
{sessionView.sessionName}
+
+
+
+ {formatTimeAgo(sessionView.sessionTime, currentTime, t)}
+
+ {sessionView.messageCount > 0 && (
+
+ {sessionView.messageCount}
+
+ )}
+
+
+
+
+
+
+
+
+ {!sessionView.isCursorSession && (
+
+ {editingSession === session.id && !sessionView.isCodexSession ? (
+ <>
+ onEditingSessionNameChange(event.target.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === 'Enter') {
+ saveEditedSession();
+ } else if (event.key === 'Escape') {
+ onCancelEditingSession();
+ }
+ }}
+ onClick={(event) => event.stopPropagation()}
+ className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
+ autoFocus
+ />
+ {
+ event.stopPropagation();
+ saveEditedSession();
+ }}
+ title={t('tooltips.save')}
+ >
+
+
+ {
+ event.stopPropagation();
+ onCancelEditingSession();
+ }}
+ title={t('tooltips.cancel')}
+ >
+
+
+ >
+ ) : (
+ <>
+ {!sessionView.isCodexSession && (
+ {
+ event.stopPropagation();
+ onStartEditingSession(session.id, session.summary || t('projects.newSession'));
+ }}
+ title={t('tooltips.editSessionName')}
+ >
+
+
+ )}
+ {
+ event.stopPropagation();
+ requestDeleteSession();
+ }}
+ title={t('tooltips.deleteSession')}
+ >
+
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx
deleted file mode 100644
index 7d6e362..0000000
--- a/src/components/ui/badge.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import * as React from "react"
-import { cva } from "class-variance-authority"
-import { cn } from "../../lib/utils"
-
-const badgeVariants = cva(
- "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
- {
- variants: {
- variant: {
- default:
- "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
- destructive:
- "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
- outline: "text-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function Badge({ className, variant, ...props }) {
- return (
-
- )
-}
-
-export { Badge, badgeVariants }
\ No newline at end of file
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..d25e38f
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '../../lib/utils';
+
+const badgeVariants = cva(
+ 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+ {
+ variants: {
+ variant: {
+ default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
+ secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ destructive:
+ 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
+ outline: 'text-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return
;
+}
+
+export { Badge, badgeVariants };
diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx
deleted file mode 100644
index 996ac34..0000000
--- a/src/components/ui/button.jsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as React from "react"
-import { cva } from "class-variance-authority"
-import { cn } from "../../lib/utils"
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "bg-primary text-primary-foreground shadow hover:bg-primary/90",
- destructive:
- "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
- outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary:
- "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
- return (
-
- )
-})
-Button.displayName = "Button"
-
-export { Button, buttonVariants }
\ No newline at end of file
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..e07c177
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '../../lib/utils';
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2',
+ sm: 'h-8 rounded-md px-3 text-xs',
+ lg: 'h-10 rounded-md px-8',
+ icon: 'h-9 w-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Button.displayName = 'Button';
+
+export { Button, buttonVariants };
diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx
deleted file mode 100644
index a3c138e..0000000
--- a/src/components/ui/input.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react"
-import { cn } from "../../lib/utils"
-
-const Input = React.forwardRef(({ className, type, ...props }, ref) => {
- return (
-
- )
-})
-Input.displayName = "Input"
-
-export { Input }
\ No newline at end of file
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..9add5cf
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import { cn } from '../../lib/utils';
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/src/components/ui/scroll-area.jsx b/src/components/ui/scroll-area.jsx
deleted file mode 100644
index 334b36b..0000000
--- a/src/components/ui/scroll-area.jsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as React from "react"
-import { cn } from "../../lib/utils"
-
-const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
-
-))
-ScrollArea.displayName = "ScrollArea"
-
-export { ScrollArea }
\ No newline at end of file
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..bb70534
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { cn } from '../../lib/utils';
+
+export interface ScrollAreaProps extends React.HTMLAttributes {}
+
+const ScrollArea = React.forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ ),
+);
+ScrollArea.displayName = 'ScrollArea';
+
+export { ScrollArea };
diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx
index ad31f66..2f2677f 100644
--- a/src/contexts/WebSocketContext.tsx
+++ b/src/contexts/WebSocketContext.tsx
@@ -94,12 +94,12 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const sendMessage = useCallback((message: any) => {
const socket = wsRef.current;
- if (socket && isConnected) {
+ if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
- }, [isConnected]);
+ }, []);
const value: WebSocketContextType = useMemo(() =>
({
diff --git a/src/hooks/useAudioRecorder.js b/src/hooks/useAudioRecorder.js
deleted file mode 100755
index c02fa34..0000000
--- a/src/hooks/useAudioRecorder.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { useState, useRef, useCallback } from 'react';
-
-export function useAudioRecorder() {
- const [isRecording, setRecording] = useState(false);
- const [audioBlob, setAudioBlob] = useState(null);
- const [error, setError] = useState(null);
- const mediaRecorderRef = useRef(null);
- const streamRef = useRef(null);
- const chunksRef = useRef([]);
-
- const start = useCallback(async () => {
- try {
- setError(null);
- setAudioBlob(null);
- chunksRef.current = [];
-
- // Request microphone access
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: {
- echoCancellation: true,
- noiseSuppression: true,
- sampleRate: 16000,
- }
- });
-
- streamRef.current = stream;
-
- // Determine supported MIME type
- const mimeType = MediaRecorder.isTypeSupported('audio/webm')
- ? 'audio/webm'
- : 'audio/mp4';
-
- // Create media recorder
- const recorder = new MediaRecorder(stream, { mimeType });
- mediaRecorderRef.current = recorder;
-
- // Set up event handlers
- recorder.ondataavailable = (e) => {
- if (e.data.size > 0) {
- chunksRef.current.push(e.data);
- }
- };
-
- recorder.onstop = () => {
- // Create blob from chunks
- const blob = new Blob(chunksRef.current, { type: mimeType });
- setAudioBlob(blob);
-
- // Clean up stream
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
- };
-
- recorder.onerror = (event) => {
- console.error('MediaRecorder error:', event);
- setError('Recording failed');
- setRecording(false);
- };
-
- // Start recording
- recorder.start();
- setRecording(true);
- console.log('Recording started');
- } catch (err) {
- console.error('Failed to start recording:', err);
- setError(err.message || 'Failed to start recording');
- setRecording(false);
- }
- }, []);
-
- const stop = useCallback(() => {
- console.log('Stop called, recorder state:', mediaRecorderRef.current?.state);
-
- try {
- if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
- mediaRecorderRef.current.stop();
- console.log('Recording stopped');
- }
- } catch (err) {
- console.error('Error stopping recorder:', err);
- }
-
- // Always update state
- setRecording(false);
-
- // Clean up stream if still active
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
- }, []);
-
- const reset = useCallback(() => {
- setAudioBlob(null);
- setError(null);
- chunksRef.current = [];
- }, []);
-
- return {
- isRecording,
- audioBlob,
- error,
- start,
- stop,
- reset
- };
-}
\ No newline at end of file
diff --git a/src/hooks/useDeviceSettings.ts b/src/hooks/useDeviceSettings.ts
new file mode 100644
index 0000000..ff7bbbf
--- /dev/null
+++ b/src/hooks/useDeviceSettings.ts
@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+
+type UseDeviceSettingsOptions = {
+ mobileBreakpoint?: number;
+ trackMobile?: boolean;
+ trackPWA?: boolean;
+};
+
+const getIsMobile = (mobileBreakpoint: number): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return window.innerWidth < mobileBreakpoint;
+};
+
+const getIsPWA = (): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };
+
+ return (
+ window.matchMedia('(display-mode: standalone)').matches ||
+ Boolean(navigatorWithStandalone.standalone) ||
+ document.referrer.includes('android-app://')
+ );
+};
+
+export function useDeviceSettings(options: UseDeviceSettingsOptions = {}) {
+ const {
+ mobileBreakpoint = 768,
+ trackMobile = true,
+ trackPWA = true
+ } = options;
+
+ const [isMobile, setIsMobile] = useState(() => (
+ trackMobile ? getIsMobile(mobileBreakpoint) : false
+ ));
+ const [isPWA, setIsPWA] = useState(() => (
+ trackPWA ? getIsPWA() : false
+ ));
+
+ useEffect(() => {
+ if (!trackMobile || typeof window === 'undefined') {
+ return;
+ }
+
+ const checkMobile = () => {
+ setIsMobile(getIsMobile(mobileBreakpoint));
+ };
+
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+
+ return () => {
+ window.removeEventListener('resize', checkMobile);
+ };
+ }, [mobileBreakpoint, trackMobile]);
+
+ useEffect(() => {
+ if (!trackPWA || typeof window === 'undefined') {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia('(display-mode: standalone)');
+ const checkPWA = () => {
+ setIsPWA(getIsPWA());
+ };
+
+ checkPWA();
+
+ if (typeof mediaQuery.addEventListener === 'function') {
+ mediaQuery.addEventListener('change', checkPWA);
+ return () => {
+ mediaQuery.removeEventListener('change', checkPWA);
+ };
+ }
+
+ mediaQuery.addListener(checkPWA);
+ return () => {
+ mediaQuery.removeListener(checkPWA);
+ };
+ }, [trackPWA]);
+
+ return { isMobile, isPWA };
+}
diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts
new file mode 100644
index 0000000..d2a08df
--- /dev/null
+++ b/src/hooks/useProjectsState.ts
@@ -0,0 +1,517 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { NavigateFunction } from 'react-router-dom';
+import { api } from '../utils/api';
+import type {
+ AppSocketMessage,
+ AppTab,
+ LoadingProgress,
+ Project,
+ ProjectSession,
+ ProjectsUpdatedMessage,
+} from '../types/app';
+
+type UseProjectsStateArgs = {
+ sessionId?: string;
+ navigate: NavigateFunction;
+ latestMessage: AppSocketMessage | null;
+ isMobile: boolean;
+ activeSessions: Set;
+};
+
+const serialize = (value: unknown) => JSON.stringify(value ?? null);
+
+const projectsHaveChanges = (
+ prevProjects: Project[],
+ nextProjects: Project[],
+ includeExternalSessions: boolean,
+): boolean => {
+ if (prevProjects.length !== nextProjects.length) {
+ return true;
+ }
+
+ return nextProjects.some((nextProject, index) => {
+ const prevProject = prevProjects[index];
+ if (!prevProject) {
+ return true;
+ }
+
+ const baseChanged =
+ nextProject.name !== prevProject.name ||
+ nextProject.displayName !== prevProject.displayName ||
+ nextProject.fullPath !== prevProject.fullPath ||
+ serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
+ serialize(nextProject.sessions) !== serialize(prevProject.sessions);
+
+ if (baseChanged) {
+ return true;
+ }
+
+ if (!includeExternalSessions) {
+ return false;
+ }
+
+ return (
+ serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
+ serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions)
+ );
+ });
+};
+
+const getProjectSessions = (project: Project): ProjectSession[] => {
+ return [
+ ...(project.sessions ?? []),
+ ...(project.codexSessions ?? []),
+ ...(project.cursorSessions ?? []),
+ ];
+};
+
+const isUpdateAdditive = (
+ currentProjects: Project[],
+ updatedProjects: Project[],
+ selectedProject: Project | null,
+ selectedSession: ProjectSession | null,
+): boolean => {
+ if (!selectedProject || !selectedSession) {
+ return true;
+ }
+
+ const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name);
+ const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name);
+
+ if (!currentSelectedProject || !updatedSelectedProject) {
+ return false;
+ }
+
+ const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+ const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (!currentSelectedSession || !updatedSelectedSession) {
+ return false;
+ }
+
+ return (
+ currentSelectedSession.id === updatedSelectedSession.id &&
+ currentSelectedSession.title === updatedSelectedSession.title &&
+ currentSelectedSession.created_at === updatedSelectedSession.created_at &&
+ currentSelectedSession.updated_at === updatedSelectedSession.updated_at
+ );
+};
+
+export function useProjectsState({
+ sessionId,
+ navigate,
+ latestMessage,
+ isMobile,
+ activeSessions,
+}: UseProjectsStateArgs) {
+ const [projects, setProjects] = useState([]);
+ const [selectedProject, setSelectedProject] = useState(null);
+ const [selectedSession, setSelectedSession] = useState(null);
+ const [activeTab, setActiveTab] = useState('chat');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [isLoadingProjects, setIsLoadingProjects] = useState(true);
+ const [loadingProgress, setLoadingProgress] = useState(null);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+ const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
+ const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
+
+ const loadingProgressTimeoutRef = useRef | null>(null);
+
+ const fetchProjects = useCallback(async () => {
+ try {
+ setIsLoadingProjects(true);
+ const response = await api.projects();
+ const projectData = (await response.json()) as Project[];
+
+ setProjects((prevProjects) => {
+ if (prevProjects.length === 0) {
+ return projectData;
+ }
+
+ return projectsHaveChanges(prevProjects, projectData, true)
+ ? projectData
+ : prevProjects;
+ });
+ } catch (error) {
+ console.error('Error fetching projects:', error);
+ } finally {
+ setIsLoadingProjects(false);
+ }
+ }, []);
+
+ const openSettings = useCallback((tab = 'tools') => {
+ setSettingsInitialTab(tab);
+ setShowSettings(true);
+ }, []);
+
+ useEffect(() => {
+ void fetchProjects();
+ }, [fetchProjects]);
+
+ useEffect(() => {
+ if (!latestMessage) {
+ return;
+ }
+
+ if (latestMessage.type === 'loading_progress') {
+ if (loadingProgressTimeoutRef.current) {
+ clearTimeout(loadingProgressTimeoutRef.current);
+ loadingProgressTimeoutRef.current = null;
+ }
+
+ setLoadingProgress(latestMessage as LoadingProgress);
+
+ if (latestMessage.phase === 'complete') {
+ loadingProgressTimeoutRef.current = setTimeout(() => {
+ setLoadingProgress(null);
+ loadingProgressTimeoutRef.current = null;
+ }, 500);
+ }
+
+ return;
+ }
+
+ if (latestMessage.type !== 'projects_updated') {
+ return;
+ }
+
+ const projectsMessage = latestMessage as ProjectsUpdatedMessage;
+
+ if (projectsMessage.changedFile && selectedSession && selectedProject) {
+ const normalized = projectsMessage.changedFile.replace(/\\/g, '/');
+ const changedFileParts = normalized.split('/');
+
+ if (changedFileParts.length >= 2) {
+ const filename = changedFileParts[changedFileParts.length - 1];
+ const changedSessionId = filename.replace('.jsonl', '');
+
+ if (changedSessionId === selectedSession.id) {
+ const isSessionActive = activeSessions.has(selectedSession.id);
+
+ if (!isSessionActive) {
+ setExternalMessageUpdate((prev) => prev + 1);
+ }
+ }
+ }
+ }
+
+ const hasActiveSession =
+ (selectedSession && activeSessions.has(selectedSession.id)) ||
+ (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
+
+ const updatedProjects = projectsMessage.projects;
+
+ if (
+ hasActiveSession &&
+ !isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
+ ) {
+ return;
+ }
+
+ setProjects(updatedProjects);
+
+ if (!selectedProject) {
+ return;
+ }
+
+ const updatedSelectedProject = updatedProjects.find(
+ (project) => project.name === selectedProject.name,
+ );
+
+ if (!updatedSelectedProject) {
+ return;
+ }
+
+ if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
+ setSelectedProject(updatedSelectedProject);
+ }
+
+ if (!selectedSession) {
+ return;
+ }
+
+ const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (!updatedSelectedSession) {
+ setSelectedSession(null);
+ }
+ }, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
+
+ useEffect(() => {
+ return () => {
+ if (loadingProgressTimeoutRef.current) {
+ clearTimeout(loadingProgressTimeoutRef.current);
+ loadingProgressTimeoutRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!sessionId || projects.length === 0) {
+ return;
+ }
+
+ const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
+
+ for (const project of projects) {
+ const claudeSession = project.sessions?.find((session) => session.id === sessionId);
+ if (claudeSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...claudeSession, __provider: 'claude' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+
+ const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
+ if (cursorSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...cursorSession, __provider: 'cursor' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+
+ const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
+ if (codexSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...codexSession, __provider: 'codex' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+ }
+ }, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
+
+ const handleProjectSelect = useCallback(
+ (project: Project) => {
+ setSelectedProject(project);
+ setSelectedSession(null);
+ navigate('/');
+
+ if (isMobile) {
+ setSidebarOpen(false);
+ }
+ },
+ [isMobile, navigate],
+ );
+
+ const handleSessionSelect = useCallback(
+ (session: ProjectSession) => {
+ setSelectedSession(session);
+
+ if (activeTab !== 'git' && activeTab !== 'preview') {
+ setActiveTab('chat');
+ }
+
+ const provider = localStorage.getItem('selected-provider') || 'claude';
+ if (provider === 'cursor') {
+ sessionStorage.setItem('cursorSessionId', session.id);
+ }
+
+ if (isMobile) {
+ const sessionProjectName = session.__projectName;
+ const currentProjectName = selectedProject?.name;
+
+ if (sessionProjectName !== currentProjectName) {
+ setSidebarOpen(false);
+ }
+ }
+
+ navigate(`/session/${session.id}`);
+ },
+ [activeTab, isMobile, navigate, selectedProject?.name],
+ );
+
+ const handleNewSession = useCallback(
+ (project: Project) => {
+ setSelectedProject(project);
+ setSelectedSession(null);
+ setActiveTab('chat');
+ navigate('/');
+
+ if (isMobile) {
+ setSidebarOpen(false);
+ }
+ },
+ [isMobile, navigate],
+ );
+
+ const handleSessionDelete = useCallback(
+ (sessionIdToDelete: string) => {
+ if (selectedSession?.id === sessionIdToDelete) {
+ setSelectedSession(null);
+ navigate('/');
+ }
+
+ setProjects((prevProjects) =>
+ prevProjects.map((project) => ({
+ ...project,
+ sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
+ sessionMeta: {
+ ...project.sessionMeta,
+ total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1),
+ },
+ })),
+ );
+ },
+ [navigate, selectedSession?.id],
+ );
+
+ const handleSidebarRefresh = useCallback(async () => {
+ try {
+ const response = await api.projects();
+ const freshProjects = (await response.json()) as Project[];
+
+ setProjects((prevProjects) =>
+ projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects,
+ );
+
+ if (!selectedProject) {
+ return;
+ }
+
+ const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name);
+ if (!refreshedProject) {
+ return;
+ }
+
+ if (serialize(refreshedProject) !== serialize(selectedProject)) {
+ setSelectedProject(refreshedProject);
+ }
+
+ if (!selectedSession) {
+ return;
+ }
+
+ const refreshedSession = getProjectSessions(refreshedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (refreshedSession) {
+ // Keep provider metadata stable when refreshed payload doesn't include __provider.
+ const normalizedRefreshedSession =
+ refreshedSession.__provider || !selectedSession.__provider
+ ? refreshedSession
+ : { ...refreshedSession, __provider: selectedSession.__provider };
+
+ if (serialize(normalizedRefreshedSession) !== serialize(selectedSession)) {
+ setSelectedSession(normalizedRefreshedSession);
+ }
+ }
+ } catch (error) {
+ console.error('Error refreshing sidebar:', error);
+ }
+ }, [selectedProject, selectedSession]);
+
+ const handleProjectDelete = useCallback(
+ (projectName: string) => {
+ if (selectedProject?.name === projectName) {
+ setSelectedProject(null);
+ setSelectedSession(null);
+ navigate('/');
+ }
+
+ setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName));
+ },
+ [navigate, selectedProject?.name],
+ );
+
+ const sidebarSharedProps = useMemo(
+ () => ({
+ projects,
+ selectedProject,
+ selectedSession,
+ onProjectSelect: handleProjectSelect,
+ onSessionSelect: handleSessionSelect,
+ onNewSession: handleNewSession,
+ onSessionDelete: handleSessionDelete,
+ onProjectDelete: handleProjectDelete,
+ isLoading: isLoadingProjects,
+ loadingProgress,
+ onRefresh: handleSidebarRefresh,
+ onShowSettings: () => setShowSettings(true),
+ showSettings,
+ settingsInitialTab,
+ onCloseSettings: () => setShowSettings(false),
+ isMobile,
+ }),
+ [
+ handleNewSession,
+ handleProjectDelete,
+ handleProjectSelect,
+ handleSessionDelete,
+ handleSessionSelect,
+ handleSidebarRefresh,
+ isLoadingProjects,
+ isMobile,
+ loadingProgress,
+ projects,
+ settingsInitialTab,
+ selectedProject,
+ selectedSession,
+ showSettings,
+ ],
+ );
+
+ return {
+ projects,
+ selectedProject,
+ selectedSession,
+ activeTab,
+ sidebarOpen,
+ isLoadingProjects,
+ loadingProgress,
+ isInputFocused,
+ showSettings,
+ settingsInitialTab,
+ externalMessageUpdate,
+ setActiveTab,
+ setSidebarOpen,
+ setIsInputFocused,
+ setShowSettings,
+ openSettings,
+ fetchProjects,
+ sidebarSharedProps,
+ handleProjectSelect,
+ handleSessionSelect,
+ handleNewSession,
+ handleSessionDelete,
+ handleProjectDelete,
+ handleSidebarRefresh,
+ };
+}
diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts
new file mode 100644
index 0000000..0c3d1ba
--- /dev/null
+++ b/src/hooks/useSessionProtection.ts
@@ -0,0 +1,73 @@
+import { useCallback, useState } from 'react';
+
+export function useSessionProtection() {
+ const [activeSessions, setActiveSessions] = useState>(new Set());
+ const [processingSessions, setProcessingSessions] = useState>(new Set());
+
+ const markSessionAsActive = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => new Set([...prev, sessionId]));
+ }, []);
+
+ const markSessionAsInactive = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => {
+ const next = new Set(prev);
+ next.delete(sessionId);
+ return next;
+ });
+ }, []);
+
+ const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setProcessingSessions((prev) => new Set([...prev, sessionId]));
+ }, []);
+
+ const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setProcessingSessions((prev) => {
+ const next = new Set(prev);
+ next.delete(sessionId);
+ return next;
+ });
+ }, []);
+
+ const replaceTemporarySession = useCallback((realSessionId?: string | null) => {
+ if (!realSessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => {
+ const next = new Set();
+ for (const sessionId of prev) {
+ if (!sessionId.startsWith('new-session-')) {
+ next.add(sessionId);
+ }
+ }
+ next.add(realSessionId);
+ return next;
+ });
+ }, []);
+
+ return {
+ activeSessions,
+ processingSessions,
+ markSessionAsActive,
+ markSessionAsInactive,
+ markSessionAsProcessing,
+ markSessionAsNotProcessing,
+ replaceTemporarySession,
+ };
+}
diff --git a/src/hooks/useUiPreferences.ts b/src/hooks/useUiPreferences.ts
new file mode 100644
index 0000000..eb0b833
--- /dev/null
+++ b/src/hooks/useUiPreferences.ts
@@ -0,0 +1,238 @@
+import { useEffect, useReducer, useRef } from 'react';
+
+type UiPreferences = {
+ autoExpandTools: boolean;
+ showRawParameters: boolean;
+ showThinking: boolean;
+ autoScrollToBottom: boolean;
+ sendByCtrlEnter: boolean;
+ sidebarVisible: boolean;
+};
+
+type UiPreferenceKey = keyof UiPreferences;
+
+type SetPreferenceAction = {
+ type: 'set';
+ key: UiPreferenceKey;
+ value: unknown;
+};
+
+type SetManyPreferencesAction = {
+ type: 'set_many';
+ value?: Partial>;
+};
+
+type ResetPreferencesAction = {
+ type: 'reset';
+ value?: Partial;
+};
+
+type UiPreferencesAction =
+ | SetPreferenceAction
+ | SetManyPreferencesAction
+ | ResetPreferencesAction;
+
+const DEFAULTS: UiPreferences = {
+ autoExpandTools: false,
+ showRawParameters: false,
+ showThinking: true,
+ autoScrollToBottom: true,
+ sendByCtrlEnter: false,
+ sidebarVisible: true,
+};
+
+const PREFERENCE_KEYS = Object.keys(DEFAULTS) as UiPreferenceKey[];
+const VALID_KEYS = new Set(PREFERENCE_KEYS); // prevents unknown keys from being written
+const SYNC_EVENT = 'ui-preferences:sync';
+
+type SyncEventDetail = {
+ storageKey: string;
+ sourceId: string;
+ value: Partial>;
+};
+
+const parseBoolean = (value: unknown, fallback: boolean): boolean => {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+
+ if (typeof value === 'string') {
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ }
+
+ return fallback;
+};
+
+const readLegacyPreference = (key: UiPreferenceKey, fallback: boolean): boolean => {
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw === null) return fallback;
+
+ // Supports values written by both JSON.stringify and plain strings.
+ const parsed = JSON.parse(raw);
+ return parseBoolean(parsed, fallback);
+ } catch {
+ return fallback;
+ }
+};
+
+const readInitialPreferences = (storageKey: string): UiPreferences => {
+ if (typeof window === 'undefined') {
+ return DEFAULTS;
+ }
+
+ try {
+ const raw = localStorage.getItem(storageKey);
+
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ const parsedRecord = parsed as Record;
+
+ return PREFERENCE_KEYS.reduce((acc, key) => {
+ acc[key] = parseBoolean(parsedRecord[key], DEFAULTS[key]);
+ return acc;
+ }, { ...DEFAULTS });
+ }
+ }
+ } catch {
+ // Fall back to legacy keys when unified key is missing or invalid.
+ }
+
+ return PREFERENCE_KEYS.reduce((acc, key) => {
+ acc[key] = readLegacyPreference(key, DEFAULTS[key]);
+ return acc;
+ }, { ...DEFAULTS });
+};
+
+function reducer(state: UiPreferences, action: UiPreferencesAction): UiPreferences {
+ switch (action.type) {
+ case 'set': {
+ const { key, value } = action;
+ if (!VALID_KEYS.has(key)) {
+ return state;
+ }
+
+ const nextValue = parseBoolean(value, state[key]);
+ if (state[key] === nextValue) {
+ return state;
+ }
+
+ return { ...state, [key]: nextValue };
+ }
+ case 'set_many': {
+ const updates = action.value || {};
+ let changed = false;
+ const nextState = { ...state };
+
+ for (const key of PREFERENCE_KEYS) {
+ if (!(key in updates)) continue;
+
+ const value = updates[key];
+ const nextValue = parseBoolean(value, state[key]);
+ if (nextState[key] !== nextValue) {
+ nextState[key] = nextValue;
+ changed = true;
+ }
+ }
+
+ return changed ? nextState : state;
+ }
+ case 'reset':
+ return { ...DEFAULTS, ...(action.value || {}) };
+ default:
+ return state;
+ }
+}
+
+export function useUiPreferences(storageKey = 'uiPreferences') {
+ const instanceIdRef = useRef(`ui-preferences-${Math.random().toString(36).slice(2)}`);
+ const [state, dispatch] = useReducer(
+ reducer,
+ storageKey,
+ readInitialPreferences
+ );
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ localStorage.setItem(storageKey, JSON.stringify(state));
+
+ window.dispatchEvent(
+ new CustomEvent(SYNC_EVENT, {
+ detail: {
+ storageKey,
+ sourceId: instanceIdRef.current,
+ value: state,
+ },
+ })
+ );
+ }, [state, storageKey]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const applyExternalUpdate = (value: unknown) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return;
+ }
+ dispatch({ type: 'set_many', value: value as Partial> });
+ };
+
+ const handleStorageChange = (event: StorageEvent) => {
+ if (event.key !== storageKey || event.newValue === null) {
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(event.newValue);
+ applyExternalUpdate(parsed);
+ } catch {
+ // Ignore malformed storage updates.
+ }
+ };
+
+ const handleSyncEvent = (event: Event) => {
+ const syncEvent = event as CustomEvent;
+ const detail = syncEvent.detail;
+ if (!detail || detail.storageKey !== storageKey || detail.sourceId === instanceIdRef.current) {
+ return;
+ }
+
+ applyExternalUpdate(detail.value);
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ window.addEventListener(SYNC_EVENT, handleSyncEvent as EventListener);
+
+ return () => {
+ window.removeEventListener('storage', handleStorageChange);
+ window.removeEventListener(SYNC_EVENT, handleSyncEvent as EventListener);
+ };
+ }, [storageKey]);
+
+ const setPreference = (key: UiPreferenceKey, value: unknown) => {
+ dispatch({ type: 'set', key, value });
+ };
+
+ const setPreferences = (value: Partial>) => {
+ dispatch({ type: 'set_many', value });
+ };
+
+ const resetPreferences = (value?: Partial) => {
+ dispatch({ type: 'reset', value });
+ };
+
+ return {
+ preferences: state,
+ setPreference,
+ setPreferences,
+ resetPreferences,
+ dispatch,
+ };
+}
diff --git a/src/hooks/useVersionCheck.js b/src/hooks/useVersionCheck.ts
similarity index 61%
rename from src/hooks/useVersionCheck.js
rename to src/hooks/useVersionCheck.ts
index 5951537..9ce8e92 100644
--- a/src/hooks/useVersionCheck.js
+++ b/src/hooks/useVersionCheck.ts
@@ -1,11 +1,30 @@
-// hooks/useVersionCheck.js
import { useState, useEffect } from 'react';
import { version } from '../../package.json';
+import { ReleaseInfo } from '../types/sharedTypes';
-export const useVersionCheck = (owner, repo) => {
+/**
+ * Compare two semantic version strings
+ * Works only with numeric versions separated by dots (e.g. "1.2.3")
+ * @param {string} v1
+ * @param {string} v2
+ * @returns positive if v1 > v2, negative if v1 < v2, 0 if equal
+ */
+const compareVersions = (v1: string, v2: string) => {
+ const parts1 = v1.split('.').map(Number);
+ const parts2 = v2.split('.').map(Number);
+
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+ const p1 = parts1[i] || 0;
+ const p2 = parts2[i] || 0;
+ if (p1 !== p2) return p1 - p2;
+ }
+ return 0;
+};
+
+export const useVersionCheck = (owner: string, repo: string) => {
const [updateAvailable, setUpdateAvailable] = useState(false);
- const [latestVersion, setLatestVersion] = useState(null);
- const [releaseInfo, setReleaseInfo] = useState(null);
+ const [latestVersion, setLatestVersion] = useState(null);
+ const [releaseInfo, setReleaseInfo] = useState(null);
useEffect(() => {
const checkVersion = async () => {
@@ -17,7 +36,8 @@ export const useVersionCheck = (owner, repo) => {
if (data.tag_name) {
const latest = data.tag_name.replace(/^v/, '');
setLatestVersion(latest);
- setUpdateAvailable(version !== latest);
+ // Only show update if latest version is actually newer
+ setUpdateAvailable(compareVersions(latest, version) > 0);
// Store release information
setReleaseInfo({
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index 663c139..0db6d12 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -106,7 +106,9 @@
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
},
"clickToChangeMode": "Click to change permission mode (or press Tab in input)",
- "showAllCommands": "Show all commands"
+ "showAllCommands": "Show all commands",
+ "clearInput": "Clear input",
+ "scrollToBottom": "Scroll to bottom"
},
"thinkingMode": {
"selector": {
@@ -201,5 +203,11 @@
"runCommand": "Run {{command}} in {{projectName}}",
"startCli": "Starting Claude CLI in {{projectName}}",
"defaultCommand": "command"
+ },
+ "projectSelection": {
+ "startChatWithProvider": "Select a project to start chatting with {{provider}}"
+ },
+ "tasks": {
+ "nextTaskPrompt": "Start the next task"
}
}
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index 22fca56..0e76556 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -106,7 +106,9 @@
"enter": "Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어"
},
"clickToChangeMode": "클릭하여 권한 모드 변경 (또는 입력창에서 Tab)",
- "showAllCommands": "모든 명령어 보기"
+ "showAllCommands": "모든 명령어 보기",
+ "clearInput": "입력 지우기",
+ "scrollToBottom": "맨 아래로 스크롤"
},
"thinkingMode": {
"selector": {
@@ -201,5 +203,11 @@
"runCommand": "{{projectName}}에서 {{command}} 실행",
"startCli": "{{projectName}}에서 Claude CLI 시작",
"defaultCommand": "명령어"
+ },
+ "projectSelection": {
+ "startChatWithProvider": "{{provider}}와 채팅을 시작하려면 프로젝트를 선택하세요"
+ },
+ "tasks": {
+ "nextTaskPrompt": "다음 작업 시작"
}
}
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index 2d52ba4..0ad37e6 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -106,7 +106,9 @@
"enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令"
},
"clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab)",
- "showAllCommands": "显示所有命令"
+ "showAllCommands": "显示所有命令",
+ "clearInput": "清空输入",
+ "scrollToBottom": "滚动到底部"
},
"thinkingMode": {
"selector": {
@@ -201,5 +203,11 @@
"runCommand": "在 {{projectName}} 中运行 {{command}}",
"startCli": "在 {{projectName}} 中启动 Claude CLI",
"defaultCommand": "命令"
+ },
+ "projectSelection": {
+ "startChatWithProvider": "选择一个项目以开始与 {{provider}} 聊天"
+ },
+ "tasks": {
+ "nextTaskPrompt": "开始下一个任务"
}
}
diff --git a/src/index.css b/src/index.css
index 858c424..66615c9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -53,7 +53,7 @@
/* Mobile navigation dimensions - Single source of truth */
--mobile-nav-height: 60px;
--mobile-nav-padding: 12px;
- --mobile-nav-total: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), var(--mobile-nav-padding)));
+ --mobile-nav-total: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
/* Header safe area dimensions */
--header-safe-area-top: env(safe-area-inset-top, 0px);
@@ -642,7 +642,7 @@
/* Safe area support for iOS devices */
.ios-bottom-safe {
- padding-bottom: max(env(safe-area-inset-bottom), 12px);
+ padding-bottom: env(safe-area-inset-bottom);
}
/* PWA specific header adjustments - uses CSS variables for consistency */
@@ -665,7 +665,7 @@
@media screen and (max-width: 768px) {
.chat-input-mobile {
- padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
+ padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
}
diff --git a/src/main.jsx b/src/main.jsx
index 02ca6b1..cacb2db 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
-import App from './App.jsx'
+import App from './App.tsx'
import './index.css'
import 'katex/dist/katex.min.css'
diff --git a/src/types/app.ts b/src/types/app.ts
new file mode 100644
index 0000000..4a0f8fa
--- /dev/null
+++ b/src/types/app.ts
@@ -0,0 +1,69 @@
+export type SessionProvider = 'claude' | 'cursor' | 'codex';
+
+export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
+
+export interface ProjectSession {
+ id: string;
+ title?: string;
+ summary?: string;
+ name?: string;
+ createdAt?: string;
+ created_at?: string;
+ updated_at?: string;
+ lastActivity?: string;
+ messageCount?: number;
+ __provider?: SessionProvider;
+ __projectName?: string;
+ [key: string]: unknown;
+}
+
+export interface ProjectSessionMeta {
+ total?: number;
+ hasMore?: boolean;
+ [key: string]: unknown;
+}
+
+export interface ProjectTaskmasterInfo {
+ hasTaskmaster?: boolean;
+ status?: string;
+ metadata?: Record;
+ [key: string]: unknown;
+}
+
+export interface Project {
+ name: string;
+ displayName: string;
+ fullPath: string;
+ path?: string;
+ sessions?: ProjectSession[];
+ cursorSessions?: ProjectSession[];
+ codexSessions?: ProjectSession[];
+ sessionMeta?: ProjectSessionMeta;
+ taskmaster?: ProjectTaskmasterInfo;
+ [key: string]: unknown;
+}
+
+export interface LoadingProgress {
+ type?: 'loading_progress';
+ phase?: string;
+ current: number;
+ total: number;
+ currentProject?: string;
+ [key: string]: unknown;
+}
+
+export interface ProjectsUpdatedMessage {
+ type: 'projects_updated';
+ projects: Project[];
+ changedFile?: string;
+ [key: string]: unknown;
+}
+
+export interface LoadingProgressMessage extends LoadingProgress {
+ type: 'loading_progress';
+}
+
+export type AppSocketMessage =
+ | LoadingProgressMessage
+ | ProjectsUpdatedMessage
+ | { type?: string; [key: string]: unknown };
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..465e5d1
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,9 @@
+export {};
+
+declare global {
+ interface Window {
+ __ROUTER_BASENAME__?: string;
+ refreshProjects?: () => void | Promise;
+ openSettings?: (tab?: string) => void;
+ }
+}
diff --git a/src/types/react-syntax-highlighter.d.ts b/src/types/react-syntax-highlighter.d.ts
new file mode 100644
index 0000000..85d9ea5
--- /dev/null
+++ b/src/types/react-syntax-highlighter.d.ts
@@ -0,0 +1,2 @@
+declare module 'react-syntax-highlighter';
+declare module 'react-syntax-highlighter/dist/esm/styles/prism';
diff --git a/src/types/sharedTypes.ts b/src/types/sharedTypes.ts
new file mode 100644
index 0000000..71be7a7
--- /dev/null
+++ b/src/types/sharedTypes.ts
@@ -0,0 +1,6 @@
+export type ReleaseInfo = {
+ title: string;
+ body: string;
+ htmlUrl: string;
+ publishedAt: string;
+};
\ No newline at end of file
diff --git a/src/utils/api.js b/src/utils/api.js
index c423875..f680b1d 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -101,8 +101,8 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ filePath, content }),
}),
- getFiles: (projectName) =>
- authenticatedFetch(`/api/projects/${projectName}/files`),
+ getFiles: (projectName, options = {}) =>
+ authenticatedFetch(`/api/projects/${projectName}/files`, options),
transcribe: (formData) =>
authenticatedFetch('/api/transcribe', {
method: 'POST',
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
new file mode 100644
index 0000000..90ea56e
--- /dev/null
+++ b/src/utils/dateUtils.ts
@@ -0,0 +1,26 @@
+import { TFunction } from 'i18next';
+
+export const formatTimeAgo = (dateString: string, currentTime: Date, t: TFunction) => {
+ const date = new Date(dateString);
+ const now = currentTime;
+
+ // Check if date is valid
+ if (isNaN(date.getTime())) {
+ return t ? t('status.unknown') : 'Unknown';
+ }
+
+ const diffInMs = now.getTime() - date.getTime();
+ const diffInSeconds = Math.floor(diffInMs / 1000);
+ const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
+ const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
+ const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
+
+ if (diffInSeconds < 60) return t ? t('time.justNow') : 'Just now';
+ if (diffInMinutes === 1) return t ? t('time.oneMinuteAgo') : '1 min ago';
+ if (diffInMinutes < 60) return t ? t('time.minutesAgo', { count: diffInMinutes }) : `${diffInMinutes} mins ago`;
+ if (diffInHours === 1) return t ? t('time.oneHourAgo') : '1 hour ago';
+ if (diffInHours < 24) return t ? t('time.hoursAgo', { count: diffInHours }) : `${diffInHours} hours ago`;
+ if (diffInDays === 1) return t ? t('time.oneDayAgo') : '1 day ago';
+ if (diffInDays < 7) return t ? t('time.daysAgo', { count: diffInDays }) : `${diffInDays} days ago`;
+ return date.toLocaleDateString();
+};
\ No newline at end of file