Compare commits

...

11 Commits

Author SHA1 Message Date
Simos Mikelatos
dd2e4da524 Merge branch 'main' into fix/numerous-bug-fixes 2026-03-10 23:24:01 +01:00
Haileyesus
53af032d88 fix(cursor-chat): stabilize first-run UX and clean cursor message rendering
Fix three Cursor chat regressions observed on first message runs:

1. Full-screen UI refresh/flicker after first response.
2. Internal wrapper tags rendered in user messages.
3. Duplicate assistant message on response finalization.

Root causes
- Project refresh from chat completion used the global loading path,
  toggling app-level loading UI.
- Cursor history conversion rendered raw internal wrapper payloads
  as user-visible message text.
- Cursor response handling could finalize through overlapping stream/
  result paths, and stdout chunk parsing could split JSON lines.

Changes
- Added non-blocking project refresh plumbing for chat/session flows.
- Introduced fetch options in useProjectsState (showLoadingState flag).
- Added refreshProjectsSilently() to update metadata without global loading UI.
- Wired window.refreshProjects to refreshProjectsSilently in AppContent.

- Added Cursor user-message sanitization during history conversion.
- Added extractCursorUserQuery() to keep only <user_query> payload.
- Added sanitizeCursorUserMessageText() to strip internal wrappers:
  <user_info>, <agent_skills>, <available_skills>,
  <environment_context>, <environment_info>.
- Applied sanitization only for role === 'user' in
  convertCursorSessionMessages().

- Hardened Cursor backend stream parsing and finalization.
- Added line-buffered stdout parser for chunk-split JSON payloads.
- Flushed trailing unterminated stdout line on process close.
- Removed redundant content_block_stop emission on Cursor result.

- Added frontend duplicate guard in cursor-result handling.
- Skips a second assistant bubble when final result text equals
  already-rendered streamed content.

Code comments
- Added focused comments describing silent refresh behavior,
  tag stripping rationale, duplicate guard behavior, and line buffering.

Validation
- ESLint passes for touched files.
- Production build succeeds.

Files
- server/cursor-cli.js
- src/components/app/AppContent.tsx
- src/components/chat/hooks/useChatRealtimeHandlers.ts
- src/components/chat/utils/messageTransforms.ts
- src/hooks/useProjectsState.ts
2026-03-10 23:22:24 +03:00
Haileyesus
a780cb6523 fix(git-ui): prevent large commit diffs from freezing the history tab
Harden commit diff loading/rendering so opening a very large commit no longer hangs
the browser tab.

Problem
- commit history diff viewer rendered every diff line as a React node
- very large commits could create thousands of nodes and lock the UI thread
- backend always returned full commit patch payloads, amplifying frontend pressure

Backend safeguards
- add `COMMIT_DIFF_CHARACTER_LIMIT` (500,000 chars) in git routes
- update GET `/api/git/commit-diff` to truncate oversized diff payloads
- include `isTruncated` flag in response for observability/future UI handling
- append truncation marker text when server-side limit is applied

Frontend safeguards
- update `GitDiffViewer` to use bounded preview rendering:
  - character cap: 200,000
  - line cap: 1,500
- move diff preprocessing into `useMemo` for stable, one-pass preview computation
- show a clear "Large diff preview" notice when truncation is active

Impact
- commit diff expansion remains responsive even for high-change commits
- UI still shows useful diff content while avoiding tab lockups
- changes apply to shared diff viewer usage and improve resilience broadly

Validation
- `node --check server/routes/git.js`
- `npm run typecheck`
- `npx eslint src/components/git-panel/view/shared/GitDiffViewer.tsx`
2026-03-10 23:19:52 +03:00
Haileyesus
68787e01fc fix(git): resolve file paths against repo root for paths with spaces
Fix path resolution for git file operations when project directories include spaces
or when API calls are issued from subdirectories inside a repository.

Problem
- operations like commit/discard/diff could receive file paths that were valid from
  repo root but were executed from a nested cwd
- this produced pathspec errors like:
  - warning: could not open directory '4/4/'
  - fatal: pathspec '4/hello_world.ts' did not match any files

Root cause
- file arguments were passed directly to git commands using the project cwd
- inconsistent path forms (repo-root-relative vs cwd-relative) were not normalized

Changes
- remove unsafe fallback decode in `getActualProjectPath`; fail explicitly when the
  real project path cannot be resolved
- add repository/file-path helpers:
  - `getRepositoryRootPath`
  - `normalizeRepositoryRelativeFilePath`
  - `parseStatusFilePaths`
  - `buildFilePathCandidates`
  - `resolveRepositoryFilePath`
- update file-based git endpoints to resolve paths before executing commands:
  - GET `/diff`
  - GET `/file-with-diff`
  - POST `/commit`
  - POST `/generate-commit-message`
  - POST `/discard`
  - POST `/delete-untracked`
- stage/restore/reset operations now use `--` before pathspecs for safer argument
  separation

Behavioral impact
- git operations now work reliably for repositories under directories containing spaces
- file operations are consistent even when project cwd is a subdirectory of repo root
- endpoint responses continue to preserve existing payload shapes

Verification
- syntax check: `node --check server/routes/git.js`
- typecheck: `npm run typecheck`
- reproduced failing scenario in a temp path with spaces; confirmed root-resolved
  path staging succeeds where subdir-cwd pathspec previously failed
2026-03-10 23:14:28 +03:00
Haileyesus
ed69d2fdc9 fix(git): handle repositories without commits across status and remote flows
Improve git route behavior for repositories initialized with `git init` but with
no commits yet. Previously, several routes called `git rev-parse --abbrev-ref HEAD`,
which fails before the first commit and caused noisy console errors plus a broken
Git panel state.

What changed
- add `getGitErrorDetails` helper to normalize git process failure text
- add `isMissingHeadRevisionError` helper to detect no-HEAD/no-revision cases
- add `getCurrentBranchName` helper:
  - uses `git symbolic-ref --short HEAD` first (works before first commit)
  - falls back to `git rev-parse --abbrev-ref HEAD` for detached HEAD and edge cases
- add `repositoryHasCommits` helper using `git rev-parse --verify HEAD`

Status route improvements
- replace inline branch/HEAD error handling with shared helpers
- keep returning valid branch + `hasCommits: false` for fresh repositories

Remote status improvements
- avoid hard failure when repository has no commits
- return a safe, non-error payload with:
  - `hasUpstream: false`
  - `ahead: 0`, `behind: 0`
  - detected remote name when remotes exist
  - message: "Repository has no commits yet"
- preserve existing upstream detection behavior for repositories with commits

Consistency updates
- switch fetch/pull/push/publish branch lookup to shared `getCurrentBranchName`
  to ensure the same branch-resolution behavior everywhere

Result
- `git init` repositories no longer trigger `rev-parse HEAD` ambiguity failures
- Git panel remains usable before the first commit
- backend branch detection is centralized and consistent across git operations
2026-03-10 22:57:49 +03:00
Haileyesus
3e617394b7 fix: run cursor with --trust if workspace trust prompt is detected, and retry once 2026-03-10 22:54:19 +03:00
Haileyesus
52d4671504 feat(git): add revert latest local commit action in git panel
Add a complete revert-local-commit flow so users can undo the most recent
local commit directly from the Git header, placed before the refresh icon.

Backend
- add POST /api/git/revert-local-commit endpoint in server/routes/git.js
- validate project input and repository state before executing git operations
- revert latest commit with `git reset --soft HEAD~1` to keep changes staged
- handle initial-commit edge case by deleting HEAD ref when no parent exists
- return clear success and error responses for UI consumption

Frontend
- add useRevertLocalCommit hook to encapsulate API call and loading state
- wire hook into GitPanel and refresh git data after successful revert
- add new toolbar action in GitPanelHeader before refresh icon
- route action through existing confirmation modal flow
- disable action while request is in flight and show activity indicator

Shared UI and typing updates
- extend ConfirmActionType with `revertLocalCommit`
- add confirmation title, label, and style mappings for new action
- render RotateCcw icon for revert action in ConfirmActionModal

Result
- users can safely undo the latest local commit from the UI
- reverted commit changes remain staged for immediate recommit/edit workflows
2026-03-10 22:37:25 +03:00
Haileyesus
d508b1a4fd fix: use fallback while resuming codex and claude sessions for linux and windows 2026-03-10 22:05:49 +03:00
Haileyesus
0073c5b550 fix(shell): remove fallback command for codex and claude session resumes
The `|| claude` and `|| codex` fallback commands were causing errors as they are not valid commands.
2026-03-10 21:49:27 +03:00
Haileyesus
3a80be9492 fix(shell): restore terminal focus when switching to the shell tab
Pass shell activity state from MainContent through StandaloneShell and use it
inside Shell to explicitly focus the xterm instance once the terminal is both
initialized and connected.

Previously, switching to the Shell tab left focus on the tab button because
isActive was being ignored and the terminal never called focus() after the tab
activation lifecycle completed. As a result, users had to click inside the
terminal before keyboard input would be accepted.

This change wires isActive through the shell stack, removes the unused prop
handling in Shell, and adds a focus effect that runs when the shell becomes
active and ready. The effect uses both requestAnimationFrame and a zero-delay
timeout so focus is applied reliably after rendering and connection state
updates settle.

This restores immediate typing when opening the shell tab and also improves the
reconnect path by re-focusing the terminal after the shell connection is ready.
2026-03-10 21:47:52 +03:00
Haileyesus
78601e3bce fix(shell): copy terminal selections from xterm buffer
The shell was delegating Cmd/Ctrl+C to document.execCommand('copy'),
which copied the rendered DOM selection instead of xterm's logical
buffer text. Wrapped values like login URLs could pick up row
whitespace or line breaks and break when pasted.

Route keyboard copy through terminal.getSelection() and the shared
clipboard helper. Also intercept native copy events on the terminal
container so mouse selection and browser copy actions use the same
normalized terminal text.

Remove the copy listener during teardown to avoid leaking handlers
across terminal reinitialization.
2026-03-10 21:36:35 +03:00
18 changed files with 770 additions and 224 deletions

View File

@@ -1,20 +1,33 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID
const WORKSPACE_TRUST_PATTERNS = [
/workspace trust required/i,
/do you trust the contents of this directory/i,
/working with untrusted contents/i,
/pass --trust,\s*--yolo,\s*or -f/i
];
function isWorkspaceTrustPrompt(text = '') {
if (!text || typeof text !== 'string') {
return false;
}
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
}
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let messageBuffer = ''; // Buffer for accumulating assistant messages
let hasRetriedWithTrust = false;
let settled = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -23,61 +36,88 @@ async function spawnCursor(command, options = {}, ws) {
};
// Build Cursor CLI command
const args = [];
const baseArgs = [];
// Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) {
args.push('--resume=' + sessionId);
baseArgs.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
args.push('-p', command);
baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
args.push('--model', model);
baseArgs.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
args.push('--output-format', 'stream-json');
baseArgs.push('--output-format', 'stream-json');
}
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
args.push('-f');
console.log('⚠️ Using -f flag (skip permissions)');
baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
activeCursorProcesses.set(processKey, cursorProcess);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('📤 Cursor CLI stdout:', rawOutput);
const settleOnce = (callback) => {
if (settled) {
return;
}
settled = true;
callback();
};
const lines = rawOutput.split('\n').filter(line => line.trim());
const runCursorProcess = (args, runReason = 'initial') => {
const isTrustRetry = runReason === 'trust-retry';
let runSawWorkspaceTrustPrompt = false;
let stdoutLineBuffer = '';
if (isTrustRetry) {
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
}
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
activeCursorProcesses.set(processKey, cursorProcess);
const shouldSuppressForTrustRetry = (text) => {
if (hasRetriedWithTrust || args.includes('--trust')) {
return false;
}
if (!isWorkspaceTrustPrompt(text)) {
return false;
}
runSawWorkspaceTrustPrompt = true;
return true;
};
const processCursorOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
for (const line of lines) {
try {
const response = JSON.parse(line);
console.log('📄 Parsed JSON response:', response);
console.log('Parsed JSON response:', response);
// Handle different message types
switch (response.type) {
@@ -86,7 +126,7 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId);
console.log('Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
@@ -133,7 +173,6 @@ async function spawnCursor(command, options = {}, ws) {
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text;
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send({
@@ -154,18 +193,9 @@ async function spawnCursor(command, options = {}, ws) {
// Session complete
console.log('Cursor session result:', response);
// Send final message if we have buffered content
if (messageBuffer) {
ws.send({
type: 'claude-response',
data: {
type: 'content_block_stop'
},
sessionId: capturedSessionId || sessionId || null
});
}
// Send completion event
// Do not emit an extra content_block_stop here.
// The UI already finalizes the streaming message in cursor-result handling,
// and emitting both can produce duplicate assistant messages.
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
});
}
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
}
}
});
};
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send({
type: 'cursor-error',
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
completeLines.forEach((line) => {
processCursorOutputLine(line.trim());
});
});
if (code === 0) {
resolve();
} else {
reject(new Error(`Cursor CLI exited with code ${code}`));
}
});
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
console.error('Cursor CLI stderr:', stderrText);
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
if (shouldSuppressForTrustRetry(stderrText)) {
return;
}
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
ws.send({
type: 'cursor-error',
error: stderrText,
sessionId: capturedSessionId || sessionId || null
});
});
reject(error);
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Flush any final unterminated stdout line before completion handling.
if (stdoutLineBuffer.trim()) {
processCursorOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
if (
runSawWorkspaceTrustPrompt &&
code !== 0 &&
!hasRetriedWithTrust &&
!args.includes('--trust')
) {
hasRetriedWithTrust = true;
runCursorProcess([...args, '--trust'], 'trust-retry');
return;
}
ws.send({
type: 'claude-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
if (code === 0) {
settleOnce(() => resolve());
} else {
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send({
type: 'cursor-error',
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
settleOnce(() => reject(error));
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
};
runCursorProcess(baseArgs, 'initial');
});
}
function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
console.log(`Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -1730,8 +1730,14 @@ function handleShellConnection(ws) {
shellCommand = 'cursor-agent';
}
} else if (provider === 'codex') {
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
if (hasSession && sessionId) {
shellCommand = `codex resume "${sessionId}" || codex`;
if (os.platform() === 'win32') {
// PowerShell syntax for fallback
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `codex resume "${sessionId}" || codex`;
}
} else {
shellCommand = 'codex';
}
@@ -1765,7 +1771,11 @@ function handleShellConnection(ws) {
// Claude (default provider)
const command = initialCommand || 'claude';
if (hasSession && sessionId) {
shellCommand = `claude --resume "${sessionId}" || claude`;
if (os.platform() === 'win32') {
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
shellCommand = `claude --resume "${sessionId}" || claude`;
}
} else {
shellCommand = command;
}

View File

@@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
const router = express.Router();
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
@@ -107,8 +108,7 @@ async function getActualProjectPath(projectName) {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method
projectPath = projectName.replace(/-/g, '/');
throw new Error(`Unable to resolve project path for "${projectName}"`);
}
return validateProjectPath(projectPath);
}
@@ -166,6 +166,127 @@ async function validateGitRepository(projectPath) {
}
}
function getGitErrorDetails(error) {
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
}
function isMissingHeadRevisionError(error) {
const errorDetails = getGitErrorDetails(error).toLowerCase();
return errorDetails.includes('unknown revision')
|| errorDetails.includes('ambiguous argument')
|| errorDetails.includes('needed a single revision')
|| errorDetails.includes('bad revision');
}
async function getCurrentBranchName(projectPath) {
try {
// symbolic-ref works even when the repository has no commits.
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
const branchName = stdout.trim();
if (branchName) {
return branchName;
}
} catch (error) {
// Fall back to rev-parse for detached HEAD and older git edge cases.
}
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
return stdout.trim();
}
async function repositoryHasCommits(projectPath) {
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
return true;
} catch (error) {
if (isMissingHeadRevisionError(error)) {
return false;
}
throw error;
}
}
async function getRepositoryRootPath(projectPath) {
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
return stdout.trim();
}
function normalizeRepositoryRelativeFilePath(filePath) {
return String(filePath)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.trim();
}
function parseStatusFilePaths(statusOutput) {
return statusOutput
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim())
.map((line) => {
const statusPath = line.substring(3);
const renamedFilePath = statusPath.split(' -> ')[1];
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
})
.filter(Boolean);
}
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
const candidates = [normalizedFilePath];
if (
projectRelativePath
&& projectRelativePath !== '.'
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
) {
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
}
return Array.from(new Set(candidates.filter(Boolean)));
}
async function resolveRepositoryFilePath(projectPath, filePath) {
validateFilePath(filePath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
for (const candidateFilePath of candidateFilePaths) {
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
if (stdout.trim()) {
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePath,
};
}
}
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
if (!normalizedFilePath.includes('/')) {
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
const suffixMatches = changedFilePaths.filter(
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
);
if (suffixMatches.length === 1) {
return {
repositoryRootPath,
repositoryRelativeFilePath: suffixMatches[0],
};
}
}
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePaths[0],
};
}
// Get git status for a project
router.get('/status', async (req, res) => {
const { project } = req.query;
@@ -180,21 +301,8 @@ router.get('/status', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
// Get current branch - handle case where there are no commits yet
let branch = 'main';
let hasCommits = true;
try {
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
branch = branchOutput.trim();
} catch (error) {
// No HEAD exists - repository has no commits yet
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
hasCommits = false;
branch = 'main';
} else {
throw error;
}
}
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
// Get git status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
@@ -256,46 +364,64 @@ router.get('/diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file, projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is untracked or deleted
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
let diff;
if (isUntracked) {
// For untracked files, show the entire file content as additions
const filePath = path.join(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// For directories, show a simple message
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
} else {
const fileContent = await fs.readFile(filePath, 'utf-8');
const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n');
}
} else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions
const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: fileContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
const lines = fileContent.split('\n');
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n');
} else {
// Get diff for tracked files
// First check for unstaged changes (working tree vs index)
const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath });
const { stdout: unstagedDiff } = await spawnAsync(
'git',
['diff', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = stripDiffHeaders(unstagedDiff);
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath });
const { stdout: stagedDiff } = await spawnAsync(
'git',
['diff', '--cached', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
diff = stripDiffHeaders(stagedDiff) || '';
}
}
@@ -321,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file, projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
@@ -334,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
if (isDeleted) {
// For deleted files, get content from HEAD
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
// Get current file content
const filePath = path.join(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -352,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
if (!isUntracked) {
// Get the old content from HEAD for tracked files
try {
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
const { stdout: headContent } = await spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
oldContent = headContent;
} catch (error) {
// File might be newly added to git (staged but not committed)
@@ -430,15 +570,16 @@ router.post('/commit', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Stage selected files
for (const file of files) {
validateFilePath(file, projectPath);
await spawnAsync('git', ['add', file], { cwd: projectPath });
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
// Commit with message
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath });
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -447,6 +588,53 @@ router.post('/commit', async (req, res) => {
}
});
// Revert latest local commit (keeps changes staged)
router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
} catch (error) {
return res.status(400).json({
error: 'No local commit to revert',
details: 'This repository has no commit yet.',
});
}
try {
// Soft reset rewinds one commit while preserving all file changes in the index.
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
} catch (error) {
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
const isInitialCommit = errorDetails.includes('HEAD~1') &&
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
if (!isInitialCommit) {
throw error;
}
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
}
res.json({
success: true,
output: 'Latest local commit reverted successfully. Changes were kept staged.',
});
} catch (error) {
console.error('Git revert local commit error:', error);
res.status(500).json({ error: error.message });
}
});
// Get list of branches
router.get('/branches', async (req, res) => {
const { project } = req.query;
@@ -610,7 +798,12 @@ router.get('/commit-diff', async (req, res) => {
{ cwd: projectPath }
);
res.json({ diff: stdout });
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
const diff = isTruncated
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
: stdout;
res.json({ diff, isTruncated });
} catch (error) {
console.error('Git commit diff error:', error);
res.json({ error: error.message });
@@ -632,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Get diff for selected files
let diffContext = '';
for (const file of files) {
try {
validateFilePath(file, projectPath);
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const { stdout } = await spawnAsync(
'git', ['diff', 'HEAD', '--', file],
{ cwd: projectPath }
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath }
);
if (stdout) {
diffContext += `\n--- ${file} ---\n${stdout}`;
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
}
} catch (error) {
console.error(`Error getting diff for ${file}:`, error);
@@ -655,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
// Try to get content of untracked files
for (const file of files) {
try {
const filePath = path.join(projectPath, file);
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
} else {
diffContext += `\n--- ${file} (new directory) ---\n`;
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
@@ -831,9 +1027,30 @@ router.get('/remote-status', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const branch = currentBranch.trim();
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
const hasRemote = remotes.length > 0;
const fallbackRemoteName = hasRemote
? (remotes.includes('origin') ? 'origin' : remotes[0])
: null;
// Repositories initialized with `git init` can have a branch but no commits.
// Return a non-error state so the UI can show the initial-commit workflow.
if (!hasCommits) {
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
ahead: 0,
behind: 0,
isUpToDate: false,
message: 'Repository has no commits yet'
});
}
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
@@ -843,25 +1060,11 @@ router.get('/remote-status', async (req, res) => {
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
// No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName,
remoteName: fallbackRemoteName,
message: 'No remote tracking branch configured'
});
}
@@ -903,8 +1106,7 @@ router.post('/fetch', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const branch = currentBranch.trim();
const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
try {
@@ -945,8 +1147,7 @@ router.post('/pull', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const branch = currentBranch.trim();
const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
@@ -1014,8 +1215,7 @@ router.post('/push', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const branch = currentBranch.trim();
const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
@@ -1089,8 +1289,7 @@ router.post('/publish', async (req, res) => {
validateBranchName(branch);
// Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
const currentBranchName = currentBranch.trim();
const currentBranchName = await getCurrentBranchName(projectPath);
if (currentBranchName !== branch) {
return res.status(400).json({
@@ -1164,12 +1363,17 @@ router.post('/discard', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file, projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status to determine correct discard command
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' });
@@ -1179,7 +1383,7 @@ router.post('/discard', async (req, res) => {
if (status === '??') {
// Untracked file or directory - delete it
const filePath = path.join(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -1189,13 +1393,13 @@ router.post('/discard', async (req, res) => {
}
} else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD
await spawnAsync('git', ['restore', file], { cwd: projectPath });
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} else if (status.includes('A')) {
// Added file - unstage it
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath });
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
res.json({ success: true, message: `Changes discarded for ${file}` });
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
} catch (error) {
console.error('Git discard error:', error);
res.status(500).json({ error: error.message });
@@ -1213,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Validate file path
validateFilePath(file, projectPath);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is actually untracked
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
@@ -1231,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
}
// Delete the untracked file or directory
const filePath = path.join(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// Use rm with recursive option for directories
await fs.rm(filePath, { recursive: true, force: true });
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
} else {
await fs.unlink(filePath);
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
}
} catch (error) {
console.error('Git delete untracked error:', error);

View File

@@ -40,7 +40,7 @@ export default function AppContent() {
setIsInputFocused,
setShowSettings,
openSettings,
fetchProjects,
refreshProjectsSilently,
sidebarSharedProps,
} = useProjectsState({
sessionId,
@@ -51,14 +51,16 @@ export default function AppContent() {
});
useEffect(() => {
window.refreshProjects = fetchProjects;
// Expose a non-blocking refresh for chat/session flows.
// Full loading refreshes are still available through direct fetchProjects calls.
window.refreshProjects = refreshProjectsSilently;
return () => {
if (window.refreshProjects === fetchProjects) {
if (window.refreshProjects === refreshProjectsSilently) {
delete window.refreshProjects;
}
};
}, [fetchProjects]);
}, [refreshProjectsSilently]);
useEffect(() => {
window.openSettings = openSettings;

View File

@@ -692,14 +692,28 @@ export function useChatRealtimeHandlers({
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
const normalizedTextResult = textResult.trim();
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
textResult && textResult.trim()
normalizedTextResult
? 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()) {
} else if (normalizedTextResult) {
const lastAssistantText =
last && last.type === 'assistant' && !last.isToolUse
? String(last.content || '').trim()
: '';
// Cursor can emit the same final text through both streaming and result payloads.
// Skip adding a second assistant bubble when the final text is unchanged.
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
if (isDuplicateFinalText) {
return updated;
}
updated.push({
type: resultData.is_error ? 'error' : 'assistant',
content: textResult,

View File

@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
}
};
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
/<user_info>[\s\S]*?<\/user_info>/gi,
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
/<available_skills>[\s\S]*?<\/available_skills>/gi,
/<environment_context>[\s\S]*?<\/environment_context>/gi,
/<environment_info>[\s\S]*?<\/environment_info>/gi,
];
const extractCursorUserQuery = (rawText: string): string => {
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
if (userQueryMatches.length === 0) {
return '';
}
return userQueryMatches
.map((match) => (match[1] || '').trim())
.filter(Boolean)
.join('\n')
.trim();
};
const sanitizeCursorUserMessageText = (rawText: string): string => {
const decodedText = decodeHtmlEntities(rawText || '').trim();
if (!decodedText) {
return '';
}
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
const extractedUserQuery = extractCursorUserQuery(decodedText);
if (extractedUserQuery) {
return extractedUserQuery;
}
let sanitizedText = decodedText;
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
sanitizedText = sanitizedText.replace(pattern, '');
});
return sanitizedText.trim();
};
const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) {
return filePath;
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
console.log('Error parsing blob content:', error);
}
if (role === 'user') {
text = sanitizeCursorUserMessageText(text);
}
if (text && text.trim()) {
const message: ChatMessage = {
type: role,

View File

@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
pull: 'Confirm Pull',
push: 'Confirm Push',
publish: 'Publish Branch',
revertLocalCommit: 'Revert Local Commit',
};
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
pull: 'Pull',
push: 'Push',
publish: 'Publish',
revertLocalCommit: 'Revert Commit',
};
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'bg-green-600 hover:bg-green-700',
push: 'bg-orange-600 hover:bg-orange-700',
publish: 'bg-purple-600 hover:bg-purple-700',
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
};
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
push: 'bg-yellow-100 dark:bg-yellow-900/30',
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
};
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
pull: 'text-yellow-600 dark:text-yellow-400',
push: 'text-yellow-600 dark:text-yellow-400',
publish: 'text-yellow-600 dark:text-yellow-400',
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
};

View File

@@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { GitOperationResponse } from '../types/types';
type UseRevertLocalCommitOptions = {
projectName: string | null;
onSuccess?: () => void;
};
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
const revertLatestLocalCommit = useCallback(async () => {
if (!projectName) {
return;
}
setIsRevertingLocalCommit(true);
try {
const response = await authenticatedFetch('/api/git/revert-local-commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: projectName }),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
return;
}
onSuccess?.();
} catch (error) {
console.error('Error reverting local commit:', error);
} finally {
setIsRevertingLocalCommit(false);
}
}, [onSuccess, projectName]);
return {
isRevertingLocalCommit,
revertLatestLocalCommit,
};
}

View File

@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
export type GitPanelView = 'changes' | 'history';
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
export type FileDiffInfo = {
old_string: string;

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { useGitPanelController } from '../hooks/useGitPanelController';
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
import ChangesView from '../view/changes/ChangesView';
import HistoryView from '../view/history/HistoryView';
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
onFileOpen,
});
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
projectName: selectedProject?.name ?? null,
onSuccess: refreshAll,
});
const executeConfirmedAction = useCallback(async () => {
if (!confirmAction) {
return;
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
isPulling={isPulling}
isPushing={isPushing}
isPublishing={isPublishing}
isRevertingLocalCommit={isRevertingLocalCommit}
onRefresh={refreshAll}
onRevertLocalCommit={revertLatestLocalCommit}
onSwitchBranch={switchBranch}
onCreateBranch={createBranch}
onFetch={handleFetch}

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
import NewBranchModal from './modals/NewBranchModal';
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
isPulling: boolean;
isPushing: boolean;
isPublishing: boolean;
isRevertingLocalCommit: boolean;
onRefresh: () => void;
onRevertLocalCommit: () => Promise<void>;
onSwitchBranch: (branchName: string) => Promise<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>;
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
isPulling,
isPushing,
isPublishing,
isRevertingLocalCommit,
onRefresh,
onRevertLocalCommit,
onSwitchBranch,
onCreateBranch,
onFetch,
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
});
};
const requestRevertLocalCommitConfirmation = () => {
onRequestConfirmation({
type: 'revertLocalCommit',
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
onConfirm: onRevertLocalCommit,
});
};
const handleSwitchBranch = async (branchName: string) => {
try {
const success = await onSwitchBranch(branchName);
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
</>
)}
<button
onClick={requestRevertLocalCommitConfirmation}
disabled={isRevertingLocalCommit}
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Revert latest local commit"
>
<RotateCcw
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
/>
</button>
<button
onClick={onRefresh}
disabled={isLoading}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react';
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
import {
CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_CLASSES,
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
return <Download className="h-4 w-4" />;
}
if (actionType === 'revertLocalCommit') {
return <RotateCcw className="h-4 w-4" />;
}
return <Upload className="h-4 w-4" />;
}

View File

@@ -1,10 +1,38 @@
import { useMemo } from 'react';
type GitDiffViewerProps = {
diff: string | null;
isMobile: boolean;
wrapText: boolean;
};
const PREVIEW_CHARACTER_LIMIT = 200_000;
const PREVIEW_LINE_LIMIT = 1_500;
type DiffPreview = {
lines: string[];
isCharacterTruncated: boolean;
isLineTruncated: boolean;
};
function buildDiffPreview(diff: string): DiffPreview {
const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;
const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;
const previewLines = previewText.split('\n');
const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;
return {
lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,
isCharacterTruncated,
isLineTruncated,
};
}
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
// Render a bounded preview to keep huge commit diffs from freezing the UI thread.
const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);
const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;
if (!diff) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
return (
<div className="diff-viewer">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
{isPreviewTruncated && (
<div className="mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Large diff preview: rendering is limited to keep the tab responsive.
</div>
)}
{preview.lines.map((line, index) => renderDiffLine(line, index))}
</div>
);
}

View File

@@ -146,7 +146,12 @@ function MainContent({
{activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden">
<StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
<StandaloneShell
project={selectedProject}
session={selectedSession}
showHeader={false}
isActive={activeTab === 'shell'}
/>
</div>
)}

View File

@@ -11,6 +11,7 @@ import {
TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isCodexLoginCommand } from '../utils/auth';
import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -103,6 +104,37 @@ export function useShellTerminal({
nextTerminal.open(terminalContainerRef.current);
const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection();
if (!selection) {
return false;
}
return copyTextToClipboard(selection);
};
const handleTerminalCopy = (event: ClipboardEvent) => {
if (!nextTerminal.hasSelection()) {
return;
}
const selection = nextTerminal.getSelection();
if (!selection) {
return;
}
event.preventDefault();
if (event.clipboardData) {
event.clipboardData.setData('text/plain', selection);
return;
}
void copyTextToClipboard(selection);
};
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
@@ -132,7 +164,7 @@ export function useShellTerminal({
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
void copyTerminalSelection();
return false;
}
@@ -211,6 +243,7 @@ export function useShellTerminal({
resizeObserver.observe(terminalContainerRef.current);
return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);

View File

@@ -40,7 +40,7 @@ export default function Shell({
onProcessComplete = null,
minimal = false,
autoConnect = false,
isActive,
isActive = true,
}: ShellProps) {
const { t } = useTranslation('chat');
const [isRestarting, setIsRestarting] = useState(false);
@@ -48,9 +48,6 @@ export default function Shell({
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onOutputRef = useRef<(() => void) | null>(null);
// Keep the public API stable for existing callers that still pass `isActive`.
void isActive;
const {
terminalContainerRef,
terminalRef,
@@ -157,6 +154,24 @@ export default function Shell({
}
}, [isConnected]);
useEffect(() => {
if (!isActive || !isInitialized || !isConnected) {
return;
}
const focusTerminal = () => {
terminalRef.current?.focus();
};
const animationFrameId = window.requestAnimationFrame(focusTerminal);
const timeoutId = window.setTimeout(focusTerminal, 0);
return () => {
window.cancelAnimationFrame(animationFrameId);
window.clearTimeout(timeoutId);
};
}, [isActive, isConnected, isInitialized, terminalRef]);
const sendInput = useCallback(
(data: string) => {
sendSocketMessage(wsRef.current, { type: 'input', data });

View File

@@ -9,6 +9,7 @@ type StandaloneShellProps = {
session?: ProjectSession | null;
command?: string | null;
isPlainShell?: boolean | null;
isActive?: boolean;
autoConnect?: boolean;
onComplete?: ((exitCode: number) => void) | null;
onClose?: (() => void) | null;
@@ -24,6 +25,7 @@ export default function StandaloneShell({
session = null,
command = null,
isPlainShell = null,
isActive = true,
autoConnect = true,
onComplete = null,
onClose = null,
@@ -64,6 +66,7 @@ export default function StandaloneShell({
selectedSession={session}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
isActive={isActive}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}

View File

@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
activeSessions: Set<string>;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
const serialize = (value: unknown) => JSON.stringify(value ?? null);
const projectsHaveChanges = (
@@ -152,9 +156,11 @@ export function useProjectsState({
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchProjects = useCallback(async () => {
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try {
setIsLoadingProjects(true);
if (showLoadingState) {
setIsLoadingProjects(true);
}
const response = await api.projects();
const projectData = (await response.json()) as Project[];
@@ -170,10 +176,17 @@ export function useProjectsState({
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setIsLoadingProjects(false);
if (showLoadingState) {
setIsLoadingProjects(false);
}
}
}, []);
const refreshProjectsSilently = useCallback(async () => {
// Keep chat view stable while still syncing sidebar/session metadata in background.
await fetchProjects({ showLoadingState: false });
}, [fetchProjects]);
const openSettings = useCallback((tab = 'tools') => {
setSettingsInitialTab(tab);
setShowSettings(true);
@@ -547,6 +560,7 @@ export function useProjectsState({
setShowSettings,
openSettings,
fetchProjects,
refreshProjectsSilently,
sidebarSharedProps,
handleProjectSelect,
handleSessionSelect,