Compare commits

...

24 Commits

Author SHA1 Message Date
Haileyesus Dessie
72c4b0749e Merge pull request #277 from whittlelabs/feature/drag-sidebar-handle
feat: add draggable Quick Settings sidebar handle
2026-01-12 12:31:26 +03:00
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
Keith Morris
ea33810a4f fix: add error handling and cleanup for draggable handle
- Add try-catch for localStorage JSON.parse to handle corrupted data
- Remove invalid localStorage key when parsing fails
- Add cleanup effect to reset body styles if component unmounts while dragging
2025-12-31 17:41:52 -05:00
Keith Morris
4fe6cc4272 feat: add draggable Quick Settings sidebar handle
Add ability to reposition the Quick Settings sidebar handle by dragging it vertically on both desktop and mobile devices. The handle combines toggle and drag functionality in a single compact button.

Key features:
- Drag handle vertically to reposition on screen
- Click to toggle sidebar open/closed
- Smart gesture detection (5px threshold distinguishes drag from click)
- Visual feedback with blue grip icon during drag
- Position persists in localStorage across sessions
- Constrained to 10-90% of viewport height
- Full touch support with proper scroll prevention on mobile
- Responsive positioning (top-based on desktop, bottom-based on mobile)
2025-12-31 14:54:43 -05: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
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
13 changed files with 524 additions and 157 deletions

View File

@@ -1,6 +1,6 @@
<div align="center">
<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>
@@ -88,32 +88,31 @@ claude-code-ui
**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:
| 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
# Start the server (default command)
claude-code-ui
cloudcli start
# 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
cloudcli # Start with defaults
cloudcli -p 8080 # Start on custom port
cloudcli status # Show current configuration
```
### 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
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",
"version": "1.13.3",
"version": "1.13.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.13.3",
"version": "1.13.6",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.3",
"version": "1.13.6",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

View File

@@ -131,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60)));
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('>')} Create .env file in installation directory for persistent config`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
}
// Show help
@@ -145,19 +145,28 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝
Usage:
claude-code-ui [command]
cloudcli [command]
claude-code-ui [command] [options]
cloudcli [command] [options]
Commands:
start Start the Claude Code UI server (default)
status Show configuration and data locations
update Update to the latest version
help Show this help 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:
$ claude-code-ui # Start the server
$ cloudcli status # Show configuration
$ cloudcli help # Show help
$ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables:
PORT Set server port (default: 3001)
@@ -165,11 +174,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path
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:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -183,16 +187,110 @@ function showVersion() {
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
async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server
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
async function main() {
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) {
case 'start':
@@ -212,6 +310,9 @@ async function main() {
case '--version':
showVersion();
break;
case 'update':
await updatePackage();
break;
default:
console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n');

View File

@@ -462,7 +462,7 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
});
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try {
const { path: dirPath } = req.query;
@@ -510,9 +510,9 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
suggestions.push(...directories);
}
res.json({
res.json({
path: targetPath,
suggestions: suggestions
suggestions: suggestions
});
} catch (error) {

View File

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

View File

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

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
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) => {
try {
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) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} 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) => {
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) {
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);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
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 {
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) => {
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) {
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 {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
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 {
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) => {
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) {
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 {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} 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) => {
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) {
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 (latestMessage.changedFile && selectedSession && selectedProject) {
// 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) {
const filename = changedFileParts[changedFileParts.length - 1];
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.
*/
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 remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -1696,6 +1696,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = 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);
@@ -2710,6 +2713,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
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) {
@@ -2719,32 +2755,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude';
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
// Save current scroll position
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
// 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);
if (!scrolledNearTop) {
topLoadLockRef.current = false;
} else if (!topLoadLockRef.current) {
const didLoad = await loadOlderMessages(container);
if (didLoad) {
topLoadLockRef.current = true;
}
}
}
}, [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(() => {
// Load session messages when session changes
@@ -2873,7 +2906,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) {
const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200);
}
// 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) {
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
// This maintains protection continuity - no gap between temp ID and real ID
// 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 codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId);
setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
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 */}
<div
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"
>
{isLoadingSessionMessages && chatMessages.length === 0 ? (

View File

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

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
Settings2,
Moon,
Sun,
@@ -12,7 +12,8 @@ import {
Brain,
Sparkles,
FileText,
Languages
Languages,
GripVertical
} from 'lucide-react';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
});
const { isDarkMode } = useTheme();
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
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]);
const handleToggle = () => {
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
const newState = !localIsOpen;
setLocalIsOpen(newState);
onToggle(newState);
@@ -50,24 +210,37 @@ const QuickSettingsPanel = ({
return (
<>
{/* Pull Tab */}
<div
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
{/* Pull Tab - Combined drag handle and toggle button */}
<button
ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
localIsOpen ? 'right-64' : 'right-0'
} z-50 transition-all duration-150 ease-out`}
} 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 ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
>
<button
onClick={handleToggle}
className="bg-white dark:bg-gray-800 border 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"
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
>
{localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
</div>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div

View File

@@ -595,9 +595,44 @@ function Sidebar({
</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 && (
<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">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -616,39 +651,6 @@ function Sidebar({
</button>
)}
</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>
)}
@@ -1392,4 +1394,4 @@ function Sidebar({
);
}
export default Sidebar;
export default Sidebar;