sentry dev support + sessions handling in terminals
This commit is contained in:
@@ -80,6 +80,7 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
// Load .env file FIRST before any other imports that might use process.env
|
||||
import { config } from 'dotenv';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Load .env from apps/frontend directory
|
||||
// In development: __dirname is out/main (compiled), so go up 2 levels
|
||||
// In production: app resources directory
|
||||
const possibleEnvPaths = [
|
||||
resolve(__dirname, '../../.env'), // Development: out/main -> apps/frontend/.env
|
||||
resolve(__dirname, '../../../.env'), // Alternative: might be in different location
|
||||
resolve(process.cwd(), 'apps/frontend/.env'), // Fallback: from workspace root
|
||||
];
|
||||
|
||||
for (const envPath of possibleEnvPaths) {
|
||||
if (existsSync(envPath)) {
|
||||
config({ path: envPath });
|
||||
console.log(`[dotenv] Loaded environment from: ${envPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import { app, BrowserWindow, shell, nativeImage, session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { accessSync, readFileSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { setupIpcHandlers } from './ipc-setup';
|
||||
import { AgentManager } from './agent';
|
||||
|
||||
@@ -211,6 +211,8 @@ export function invokeClaude(
|
||||
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
// Release any previously claimed session ID before starting new session
|
||||
SessionHandler.releaseSessionId(terminal.id);
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -11,6 +11,48 @@ import { getTerminalSessionStore, type TerminalSession } from '../terminal-sessi
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Track session IDs that have been claimed by terminals to prevent race conditions.
|
||||
* When multiple terminals invoke Claude simultaneously, this prevents them from
|
||||
* all capturing the same session ID.
|
||||
*
|
||||
* Key: sessionId, Value: terminalId that claimed it
|
||||
*/
|
||||
const claimedSessionIds: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Claim a session ID for a terminal. Returns true if successful, false if already claimed.
|
||||
*/
|
||||
export function claimSessionId(sessionId: string, terminalId: string): boolean {
|
||||
const existingClaim = claimedSessionIds.get(sessionId);
|
||||
if (existingClaim && existingClaim !== terminalId) {
|
||||
debugLog('[SessionHandler] Session ID already claimed:', sessionId, 'by terminal:', existingClaim);
|
||||
return false;
|
||||
}
|
||||
claimedSessionIds.set(sessionId, terminalId);
|
||||
debugLog('[SessionHandler] Claimed session ID:', sessionId, 'for terminal:', terminalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a session ID claim when a terminal is destroyed or session changes.
|
||||
*/
|
||||
export function releaseSessionId(terminalId: string): void {
|
||||
for (const [sessionId, claimedBy] of claimedSessionIds.entries()) {
|
||||
if (claimedBy === terminalId) {
|
||||
claimedSessionIds.delete(sessionId);
|
||||
debugLog('[SessionHandler] Released session ID:', sessionId, 'from terminal:', terminalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently claimed session IDs (for exclusion during search).
|
||||
*/
|
||||
export function getClaimedSessionIds(): Set<string> {
|
||||
return new Set(claimedSessionIds.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Claude project slug from a project path.
|
||||
* Claude uses the full path with forward slashes replaced by dashes.
|
||||
@@ -56,9 +98,19 @@ export function findMostRecentClaudeSession(projectPath: string): string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Claude session created/modified after a given timestamp
|
||||
* Find a Claude session created/modified after a given timestamp.
|
||||
* Excludes session IDs that have already been claimed by other terminals
|
||||
* to prevent race conditions when multiple terminals invoke Claude simultaneously.
|
||||
*
|
||||
* @param projectPath - The project path to search sessions for
|
||||
* @param afterTimestamp - Only consider sessions modified after this timestamp
|
||||
* @param excludeSessionIds - Optional set of session IDs to exclude (already claimed)
|
||||
*/
|
||||
export function findClaudeSessionAfter(projectPath: string, afterTimestamp: number): string | null {
|
||||
export function findClaudeSessionAfter(
|
||||
projectPath: string,
|
||||
afterTimestamp: number,
|
||||
excludeSessionIds?: Set<string>
|
||||
): string | null {
|
||||
const slug = getClaudeProjectSlug(projectPath);
|
||||
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
||||
|
||||
@@ -71,17 +123,22 @@ export function findClaudeSessionAfter(projectPath: string, afterTimestamp: numb
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
sessionId: f.replace('.jsonl', ''),
|
||||
path: path.join(claudeProjectDir, f),
|
||||
mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime()
|
||||
}))
|
||||
.filter(f => f.mtime > afterTimestamp)
|
||||
// Exclude already-claimed session IDs to prevent race conditions
|
||||
.filter(f => !excludeSessionIds || !excludeSessionIds.has(f.sessionId))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files[0].name.replace('.jsonl', '');
|
||||
const sessionId = files[0].sessionId;
|
||||
debugLog('[SessionHandler] Found unclaimed session after timestamp:', sessionId, 'excluded:', excludeSessionIds?.size ?? 0);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
debugError('[SessionHandler] Error finding Claude session:', error);
|
||||
return null;
|
||||
@@ -184,7 +241,9 @@ export function getSessionsForDate(date: string, projectPath: string): TerminalS
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to capture Claude session ID by polling the session directory
|
||||
* Attempt to capture Claude session ID by polling the session directory.
|
||||
* Uses the claim mechanism to prevent race conditions when multiple terminals
|
||||
* invoke Claude simultaneously - each terminal will get a unique session ID.
|
||||
*/
|
||||
export function captureClaudeSessionId(
|
||||
terminalId: string,
|
||||
@@ -201,31 +260,44 @@ export function captureClaudeSessionId(
|
||||
|
||||
const terminal = terminals.get(terminalId);
|
||||
if (!terminal || !terminal.isClaudeMode) {
|
||||
debugLog('[SessionHandler] Terminal no longer in Claude mode, stopping session capture:', terminalId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal.claudeSessionId) {
|
||||
debugLog('[SessionHandler] Terminal already has session ID, stopping capture:', terminalId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = findClaudeSessionAfter(projectPath, startTime);
|
||||
// Get currently claimed session IDs to exclude from search
|
||||
const claimedIds = getClaimedSessionIds();
|
||||
const sessionId = findClaudeSessionAfter(projectPath, startTime, claimedIds);
|
||||
|
||||
if (sessionId) {
|
||||
terminal.claudeSessionId = sessionId;
|
||||
debugLog('[SessionHandler] Captured Claude session ID from directory:', sessionId);
|
||||
// Try to claim this session ID - if another terminal beat us to it, keep searching
|
||||
if (claimSessionId(sessionId, terminalId)) {
|
||||
terminal.claudeSessionId = sessionId;
|
||||
debugLog('[SessionHandler] Captured and claimed Claude session ID:', sessionId, 'for terminal:', terminalId);
|
||||
|
||||
if (terminal.projectPath) {
|
||||
updateClaudeSessionId(terminal.projectPath, terminalId, sessionId);
|
||||
}
|
||||
if (terminal.projectPath) {
|
||||
updateClaudeSessionId(terminal.projectPath, terminalId, sessionId);
|
||||
}
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId);
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId);
|
||||
}
|
||||
} else {
|
||||
// Session was claimed by another terminal, keep polling for a different one
|
||||
debugLog('[SessionHandler] Session ID was claimed by another terminal, continuing to poll:', sessionId);
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(checkForSession, 1000);
|
||||
}
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
setTimeout(checkForSession, 1000);
|
||||
} else {
|
||||
debugLog('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts');
|
||||
debugLog('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts for terminal:', terminalId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -57,8 +57,16 @@ export async function createTerminal(
|
||||
debugLog('[TerminalLifecycle] Injecting OAuth token from active profile');
|
||||
}
|
||||
|
||||
// Validate cwd exists - if the directory doesn't exist (e.g., worktree removed),
|
||||
// fall back to project path to prevent shell exit with code 1
|
||||
let effectiveCwd = cwd;
|
||||
if (cwd && !existsSync(cwd)) {
|
||||
debugLog('[TerminalLifecycle] Terminal cwd does not exist, falling back:', cwd, '->', projectPath || os.homedir());
|
||||
effectiveCwd = projectPath || os.homedir();
|
||||
}
|
||||
|
||||
const ptyProcess = PtyManager.spawnPtyProcess(
|
||||
cwd || os.homedir(),
|
||||
effectiveCwd || os.homedir(),
|
||||
cols,
|
||||
rows,
|
||||
profileEnv
|
||||
@@ -66,7 +74,7 @@ export async function createTerminal(
|
||||
|
||||
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid);
|
||||
|
||||
const terminalCwd = cwd || os.homedir();
|
||||
const terminalCwd = effectiveCwd || os.homedir();
|
||||
const terminal: TerminalProcess = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
@@ -234,6 +242,8 @@ export async function destroyTerminal(
|
||||
|
||||
try {
|
||||
SessionHandler.removePersistedSession(terminal);
|
||||
// Release any claimed session ID for this terminal
|
||||
SessionHandler.releaseSessionId(id);
|
||||
onCleanup(id);
|
||||
PtyManager.killPty(terminal);
|
||||
terminals.delete(id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Circle, CircleDot, Play } from 'lucide-react';
|
||||
import { CheckCircle, Circle, CircleDot, Play, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
@@ -143,7 +143,18 @@ export function ReviewStatusTree({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisComplete', { count: reviewResult.findings.length }),
|
||||
status: 'completed',
|
||||
date: reviewResult.reviewedAt
|
||||
date: reviewResult.reviewedAt,
|
||||
action: (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRunReview}
|
||||
className="ml-2 h-6 text-xs px-2 text-muted-foreground hover:text-foreground"
|
||||
title={t('prReview.rerunReview')}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useTerminalStore } from '../../stores/terminal-store';
|
||||
|
||||
interface UsePtyProcessOptions {
|
||||
@@ -25,6 +25,9 @@ export function usePtyProcess({
|
||||
const isCreatingRef = useRef(false);
|
||||
const isCreatedRef = useRef(false);
|
||||
const currentCwdRef = useRef(cwd);
|
||||
// Trigger state to force re-creation after resetForRecreate()
|
||||
// Refs don't trigger re-renders, so we need a state to ensure the effect runs
|
||||
const [recreationTrigger, setRecreationTrigger] = useState(0);
|
||||
const setTerminalStatus = useTerminalStore((state) => state.setTerminalStatus);
|
||||
const updateTerminal = useTerminalStore((state) => state.updateTerminal);
|
||||
|
||||
@@ -45,6 +48,8 @@ export function usePtyProcess({
|
||||
}, [cwd]);
|
||||
|
||||
// Create PTY process
|
||||
// recreationTrigger is included to force the effect to run after resetForRecreate()
|
||||
// since refs don't trigger re-renders
|
||||
useEffect(() => {
|
||||
// Skip creation if explicitly told to (waiting for dimensions)
|
||||
if (skipCreation) return;
|
||||
@@ -111,7 +116,8 @@ export function usePtyProcess({
|
||||
isCreatingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [terminalId, cwd, projectPath, cols, rows, skipCreation, setTerminalStatus, updateTerminal, onCreated, onError]);
|
||||
|
||||
}, [terminalId, cwd, projectPath, cols, rows, skipCreation, recreationTrigger, setTerminalStatus, updateTerminal, onCreated, onError]);
|
||||
|
||||
// Function to prepare for recreation by preventing the effect from running
|
||||
// Call this BEFORE updating the store cwd to avoid race condition
|
||||
@@ -121,9 +127,12 @@ export function usePtyProcess({
|
||||
|
||||
// Function to reset refs and allow recreation
|
||||
// Call this AFTER destroying the old terminal
|
||||
// Increments recreationTrigger to force the effect to run since refs don't trigger re-renders
|
||||
const resetForRecreate = useCallback(() => {
|
||||
isCreatedRef.current = false;
|
||||
isCreatingRef.current = false;
|
||||
// Increment trigger to force the creation effect to run
|
||||
setRecreationTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
"followup": "Follow-up",
|
||||
"initial": "Initial",
|
||||
"rerunFollowup": "Re-run follow-up review",
|
||||
"rerunReview": "Re-run review",
|
||||
"loadingMore": "Loading more PRs...",
|
||||
"scrollForMore": "Scroll for more",
|
||||
"allPRsLoaded": "All PRs loaded",
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
"followup": "Suivi",
|
||||
"initial": "Initial",
|
||||
"rerunFollowup": "Relancer la revue de suivi",
|
||||
"rerunReview": "Relancer la revue",
|
||||
"loadingMore": "Chargement des PRs...",
|
||||
"scrollForMore": "Défiler pour plus",
|
||||
"allPRsLoaded": "Tous les PRs chargés",
|
||||
|
||||
Generated
+1
-1
@@ -58,6 +58,7 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
@@ -8265,7 +8266,6 @@
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
Reference in New Issue
Block a user