Compare commits

...

23 Commits

Author SHA1 Message Date
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
simosmik
104e4260a7 Release 1.13.6 2025-12-31 08:00:36 +00:00
simosmik
8af982e706 feat: add update command to CLI for checking and installing the latest version 2025-12-31 07:59:13 +00:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
simosmik
ea19bd9a00 Release 1.13.5 2025-12-31 07:52:43 +00:00
viper151
6d4e5017d0 Merge pull request #257 from panta82/main
Fix issue: Broken pasted image upload
2025-12-31 08:49:03 +01:00
viper151
9b217ada0d Merge branch 'main' into main 2025-12-31 08:48:30 +01:00
simosmik
04efaa41f6 feat: add custom port and database path options to CLI commands 2025-12-31 07:42:01 +00:00
simosmik
5aef9c683a Release 1.13.3 2025-12-31 07:20:41 +00:00
simosmik
724cb5bb5c fix: adding shared folder to npm build 2025-12-31 07:17:39 +00:00
Ivan Pantic
19bb741af0 Fix issue: Broken pasted image upload 2025-12-12 00:27:09 +01:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
12 changed files with 328 additions and 133 deletions

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64"> <img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Claude Code UI</h1> <h1>Cloud CLI (aka Claude Code UI)</h1>
</div> </div>
@@ -88,32 +88,31 @@ claude-code-ui
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again. **To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
### CLI Commands **To update**:
```bash
cloudcli update
```
### CLI Usage
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands: After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
| Command / Option | Short | Description |
|------------------|-------|-------------|
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
| `cloudcli start` | | Start the server explicitly |
| `cloudcli status` | | Show configuration and data locations |
| `cloudcli update` | | Update to the latest version |
| `cloudcli help` | | Show help information |
| `cloudcli version` | | Show version information |
| `--port <port>` | `-p` | Set server port (default: 3001) |
| `--database-path <path>` | | Set custom database location |
**Examples:**
```bash ```bash
# Start the server (default command) cloudcli # Start with defaults
claude-code-ui cloudcli -p 8080 # Start on custom port
cloudcli start cloudcli status # Show current configuration
# Show configuration and data locations
cloudcli status
# Show help information
cloudcli help
# Show version
cloudcli version
```
**The `cloudcli status` command shows you:**
- Installation directory location
- Database location (where credentials are stored)
- Current configuration (PORT, DATABASE_PATH, etc.)
- Claude projects folder location
- Configuration file location
``` ```
### Run as Background Service (Recommended for Production) ### Run as Background Service (Recommended for Production)
@@ -134,6 +133,9 @@ pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias # Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui" pm2 start cloudcli --name "claude-code-ui"
# Start on a custom port
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
``` ```

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.2", "version": "1.13.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.2", "version": "1.13.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29", "@anthropic-ai/claude-agent-sdk": "^0.1.29",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.2", "version": "1.13.6",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -10,6 +10,7 @@
}, },
"files": [ "files": [
"server/", "server/",
"shared/",
"dist/", "dist/",
"README.md" "README.md"
], ],

View File

@@ -131,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60))); console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`); console.log(`\n${c.tip('[TIP]')} Hints:`);
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`); console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`); console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
} }
// Show help // Show help
@@ -145,19 +145,28 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════╝
Usage: Usage:
claude-code-ui [command] claude-code-ui [command] [options]
cloudcli [command] cloudcli [command] [options]
Commands: Commands:
start Start the Claude Code UI server (default) start Start the Claude Code UI server (default)
status Show configuration and data locations status Show configuration and data locations
update Update to the latest version
help Show this help information help Show this help information
version Show version information version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
--database-path <path> Set custom database location
-h, --help Show this help information
-v, --version Show version information
Examples: Examples:
$ claude-code-ui # Start the server $ cloudcli # Start with defaults
$ cloudcli status # Show configuration $ cloudcli --port 8080 # Start on port 8080
$ cloudcli help # Show help $ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables: Environment Variables:
PORT Set server port (default: 3001) PORT Set server port (default: 3001)
@@ -165,11 +174,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000) CONTEXT_WINDOW Set context window size (default: 160000)
Configuration:
Create a .env file in the installation directory to set
persistent environment variables. Use 'cloudcli status' to
see the installation directory path.
Documentation: Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'} ${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -183,16 +187,110 @@ function showVersion() {
console.log(`${packageJson.version}`); console.log(`${packageJson.version}`);
} }
// Compare semver versions, returns true if v1 > v2
function isNewerVersion(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (parts1[i] > parts2[i]) return true;
if (parts1[i] < parts2[i]) return false;
}
return false;
}
// Check for updates
async function checkForUpdates(silent = false) {
try {
const { execSync } = await import('child_process');
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
const currentVersion = packageJson.version;
if (isNewerVersion(latestVersion, currentVersion)) {
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
return { hasUpdate: true, latestVersion, currentVersion };
} else if (!silent) {
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
}
return { hasUpdate: false, latestVersion, currentVersion };
} catch (e) {
if (!silent) {
console.log(`${c.warn('[WARN]')} Could not check for updates`);
}
return { hasUpdate: false, error: e.message };
}
}
// Update the package
async function updatePackage() {
try {
const { execSync } = await import('child_process');
console.log(`${c.info('[INFO]')} Checking for updates...`);
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
if (!hasUpdate) {
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
return;
}
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
} catch (e) {
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
}
}
// Start the server // Start the server
async function startServer() { async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server // Import and run the server
await import('./index.js'); await import('./index.js');
} }
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--port' || arg === '-p') {
parsed.options.port = args[++i];
} else if (arg.startsWith('--port=')) {
parsed.options.port = arg.split('=')[1];
} else if (arg === '--database-path') {
parsed.options.databasePath = args[++i];
} else if (arg.startsWith('--database-path=')) {
parsed.options.databasePath = arg.split('=')[1];
} else if (arg === '--help' || arg === '-h') {
parsed.command = 'help';
} else if (arg === '--version' || arg === '-v') {
parsed.command = 'version';
} else if (!arg.startsWith('-')) {
parsed.command = arg;
}
}
return parsed;
}
// Main CLI handler // Main CLI handler
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const command = args[0] || 'start'; const { command, options } = parseArgs(args);
// Apply CLI options to environment variables
if (options.port) {
process.env.PORT = options.port;
}
if (options.databasePath) {
process.env.DATABASE_PATH = options.databasePath;
}
switch (command) { switch (command) {
case 'start': case 'start':
@@ -212,6 +310,9 @@ async function main() {
case '--version': case '--version':
showVersion(); showVersion();
break; break;
case 'update':
await updatePackage();
break;
default: default:
console.error(`\n❌ Unknown command: ${command}`); console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n'); console.log(' Run "cloudcli help" for usage information.\n');

View File

@@ -280,7 +280,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
sendMessage(ws, { sendMessage(ws, {
type: 'codex-complete', type: 'codex-complete',
sessionId: currentSessionId sessionId: currentSessionId,
actualSessionId: thread.id
}); });
} catch (error) { } catch (error) {

View File

@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath); const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path // Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) { // 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({ sessions.push({
id: sessionData.id, id: sessionData.id,
summary: sessionData.summary || 'Codex Session', summary: sessionData.summary || 'Codex Session',
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary // Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++; messageCount++;
if (entry.payload.text) { if (entry.payload.message) {
lastUserMessage = entry.payload.text; lastUserMessage = entry.payload.message;
} }
} }
if (entry.type === 'response_item' && entry.payload?.type === 'message') { if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++; messageCount++;
} }

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router(); const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => { router.get('/config', async (req, res) => {
try { try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml'); const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => { router.get('/mcp/cli/list', async (req, res) => {
try { try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) }); respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else { } else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr }); respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message }); res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args); cliArgs.push(...args);
} }
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message }); res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message }); res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) }); respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else { } else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr }); respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message }); res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });

View File

@@ -177,7 +177,9 @@ function AppContent() {
// If so, and the session is not active, trigger a message reload in ChatInterface // If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) { if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl") // Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/'); const normalized = latestMessage.changedFile.replace(/\\/g, '/');
const changedFileParts = normalized.split('/');
if (changedFileParts.length >= 2) { if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1]; const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', ''); const changedSessionId = filename.replace('.jsonl', '');

View File

@@ -16,7 +16,7 @@
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
*/ */
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
@@ -1696,6 +1696,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = useRef(null); const inputContainerRef = useRef(null);
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls 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 // Streaming throttle buffers
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const streamTimerRef = useRef(null); const streamTimerRef = useRef(null);
@@ -2710,6 +2713,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return scrollHeight - scrollTop - clientHeight < 50; 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 // Handle scroll events to detect when user manually scrolls up and load more messages
const handleScroll = useCallback(async () => { const handleScroll = useCallback(async () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@@ -2719,32 +2755,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top) // Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100; const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude'; if (!scrolledNearTop) {
topLoadLockRef.current = false;
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { } else if (!topLoadLockRef.current) {
// Save current scroll position const didLoad = await loadOlderMessages(container);
const previousScrollHeight = container.scrollHeight; if (didLoad) {
const previousScrollTop = container.scrollTop; topLoadLockRef.current = true;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
if (moreMessages.length > 0) {
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
// Restore scroll position after DOM update
setTimeout(() => {
if (scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - previousScrollHeight;
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
}
}, 0);
} }
} }
} }
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); }, [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(() => { useEffect(() => {
// Load session messages when session changes // Load session messages when session changes
@@ -2873,7 +2906,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo // convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom // Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) { const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
} }
// If user scrolled up, preserve their position (they're reading history) // If user scrolled up, preserve their position (they're reading history)
@@ -2971,6 +3005,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.sessionId && !currentSessionId) { if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); sessionStorage.setItem('pendingSessionId', 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 // Session Protection: Replace temporary "new-session-*" identifier with real session ID
// This maintains protection continuity - no gap between temp ID and real 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 // The temporary session is removed and real session is marked as active
@@ -3530,8 +3567,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) { if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId); setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId); console.log('Codex session complete, ID set to:', codexPendingSessionId);
} }
@@ -4410,6 +4452,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Messages Area - Scrollable Middle Section */} {/* Messages Area - Scrollable Middle Section */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative" className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (

View File

@@ -25,13 +25,15 @@ function LoginModal({
const getCommand = () => { const getCommand = () => {
if (customCommand) return customCommand; if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return 'codex login'; return isPlatform ? 'codex login --device-auth' : 'codex login';
default: default:
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
} }

View File

@@ -595,9 +595,44 @@ function Sidebar({
</div> </div>
</div> </div>
{/* Search Filter and Actions */} {/* Action Buttons - Desktop only - Always show when not loading */}
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
</div>
)}
{/* Search Filter - Only show when there are projects */}
{projects.length > 0 && !isLoading && ( {projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2"> <div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -616,39 +651,6 @@ function Sidebar({
</button> </button>
)} )}
</div> </div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div> </div>
)} )}