- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
/**
|
|
* SSE Client - Server-Sent Events for real-time session events
|
|
*
|
|
* Replaces WebSocket for receiving session events from the server.
|
|
* Commands are sent via REST API instead.
|
|
*/
|
|
|
|
class SSEClient {
|
|
constructor() {
|
|
this.eventSource = null;
|
|
this.currentSessionId = null;
|
|
this.eventHandlers = new Map();
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 10;
|
|
this.reconnectDelay = 1000;
|
|
this.reconnectBackoff = 1.5;
|
|
}
|
|
|
|
/**
|
|
* Connect to SSE endpoint for a session
|
|
* @param {string} sessionId - Session ID to connect to
|
|
*/
|
|
connect(sessionId) {
|
|
if (this.eventSource) {
|
|
this.disconnect();
|
|
}
|
|
|
|
this.currentSessionId = sessionId;
|
|
// Use /claude/api prefix for production nginx routing
|
|
const url = `/claude/api/session/${encodeURIComponent(sessionId)}/events`;
|
|
|
|
console.log(`[SSEClient] Connecting to: ${url}`);
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'connect START', { sessionId, url });
|
|
}
|
|
|
|
this.eventSource = new EventSource(url);
|
|
|
|
// Connection opened
|
|
this.eventSource.onopen = () => {
|
|
console.log(`[SSEClient] Connected to session ${sessionId}`);
|
|
this.reconnectAttempts = 0;
|
|
this.reconnectDelay = 1000;
|
|
this.emit('connected', { sessionId });
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'connected SUCCESS', { sessionId, readyState: this.eventSource.readyState });
|
|
}
|
|
|
|
// Auto-report successful connection
|
|
if (window.AutoFixLogger) {
|
|
window.AutoFixLogger.success('SSE connected', { sessionId });
|
|
}
|
|
};
|
|
|
|
// Connection error
|
|
this.eventSource.onerror = (error) => {
|
|
console.error(`[SSEClient] Connection error for session ${sessionId}:`, error);
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'connection ERROR', {
|
|
sessionId,
|
|
readyState: this.eventSource.readyState,
|
|
error: error?.toString()
|
|
});
|
|
}
|
|
|
|
// Auto-report connection error
|
|
if (window.AutoFixLogger) {
|
|
window.AutoFixLogger.error('SSE connection failed', {
|
|
sessionId,
|
|
readyState: this.eventSource.readyState,
|
|
reconnectAttempts: this.reconnectAttempts
|
|
});
|
|
}
|
|
|
|
if (this.eventSource.readyState === EventSource.CLOSED) {
|
|
this.emit('error', { sessionId, error: 'Connection closed' });
|
|
this.handleReconnect();
|
|
}
|
|
};
|
|
|
|
// Generic message handler (fallback) - only for unnamed events
|
|
this.eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log('[SSEClient] Message:', data.type, data);
|
|
this.routeEvent(data);
|
|
} catch (err) {
|
|
console.error('[SSEClient] Error parsing message:', err, event.data);
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'message parse ERROR', {
|
|
error: err?.toString(),
|
|
rawData: event.data?.substring(0, 200)
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Register specific event listeners
|
|
this.registerEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Register specific event type listeners
|
|
*/
|
|
registerEventListeners() {
|
|
const eventTypes = [
|
|
'connected',
|
|
'session-output',
|
|
'session-error',
|
|
'session-status',
|
|
'operations-detected',
|
|
'operations-executed',
|
|
'operations-error',
|
|
'approval-request',
|
|
'approval-confirmed',
|
|
'approval-expired',
|
|
'command-sent',
|
|
'terminal-created',
|
|
'terminal-closed'
|
|
];
|
|
|
|
eventTypes.forEach(type => {
|
|
this.eventSource.addEventListener(type, (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
// Use _eventType for routing to preserve the server's event type
|
|
// The 'type' in data is the event subtype (e.g., 'stdout', 'stderr')
|
|
this.routeEvent({ _eventType: type, ...data });
|
|
} catch (err) {
|
|
console.error(`[SSEClient] Error parsing ${type} event:`, err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Route event to appropriate handler
|
|
* @param {Object} event - Event object with type and data
|
|
*/
|
|
routeEvent(event) {
|
|
// Use _eventType for routing (set by server), fall back to type
|
|
const eventType = event._eventType || event.type;
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'routeEvent', {
|
|
eventType,
|
|
hasContent: !!event.content,
|
|
contentLength: event.content?.length || 0
|
|
});
|
|
}
|
|
|
|
// Call registered handlers for this event type
|
|
const handlers = this.eventHandlers.get(eventType) || [];
|
|
handlers.forEach(handler => {
|
|
try {
|
|
handler(event);
|
|
} catch (err) {
|
|
console.error(`[SSEClient] Error in handler for ${eventType}:`, err);
|
|
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'handler ERROR', {
|
|
eventType,
|
|
error: err?.toString()
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Call wildcard handlers
|
|
const wildcardHandlers = this.eventHandlers.get('*') || [];
|
|
wildcardHandlers.forEach(handler => {
|
|
try {
|
|
handler(event);
|
|
} catch (err) {
|
|
console.error('[SSEClient] Error in wildcard handler:', err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle automatic reconnection with exponential backoff
|
|
*/
|
|
handleReconnect() {
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error('[SSEClient] Max reconnection attempts reached');
|
|
this.emit('disconnected', { sessionId: this.currentSessionId });
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(this.reconnectBackoff, this.reconnectAttempts - 1);
|
|
|
|
console.log(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
|
|
setTimeout(() => {
|
|
if (this.currentSessionId) {
|
|
this.connect(this.currentSessionId);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* Register event handler
|
|
* @param {string} eventType - Event type or '*' for wildcard
|
|
* @param {Function} handler - Event handler function
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
on(eventType, handler) {
|
|
if (!this.eventHandlers.has(eventType)) {
|
|
this.eventHandlers.set(eventType, []);
|
|
}
|
|
|
|
const handlers = this.eventHandlers.get(eventType);
|
|
|
|
// Prevent duplicate handler registration
|
|
if (handlers.includes(handler)) {
|
|
console.log(`[SSEClient] Handler already registered for: ${eventType}, skipping`);
|
|
return () => {}; // Return no-op unsubscribe function
|
|
}
|
|
|
|
handlers.push(handler);
|
|
|
|
console.log(`[SSEClient] Registered handler for: ${eventType} (total: ${handlers.length})`);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
const currentHandlers = this.eventHandlers.get(eventType);
|
|
if (currentHandlers) {
|
|
const index = currentHandlers.indexOf(handler);
|
|
if (index > -1) {
|
|
currentHandlers.splice(index, 1);
|
|
console.log(`[SSEClient] Unregistered handler for: ${eventType}`);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Emit event to handlers
|
|
* @param {string} eventType - Event type
|
|
* @param {Object} data - Event data
|
|
*/
|
|
emit(eventType, data) {
|
|
if (window.traceExecution) {
|
|
window.traceExecution('sse-client', 'emit', {
|
|
eventType,
|
|
dataKeys: Object.keys(data || {})
|
|
});
|
|
}
|
|
this.routeEvent({ _eventType: eventType, ...data });
|
|
}
|
|
|
|
/**
|
|
* Disconnect from SSE endpoint
|
|
*/
|
|
disconnect() {
|
|
if (this.eventSource) {
|
|
console.log(`[SSEClient] Disconnecting from session ${this.currentSessionId}`);
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
|
|
const sessionId = this.currentSessionId;
|
|
this.currentSessionId = null;
|
|
this.reconnectAttempts = 0;
|
|
|
|
this.emit('disconnected', { sessionId });
|
|
}
|
|
|
|
/**
|
|
* Get connection state
|
|
* @returns {string} Connection state
|
|
*/
|
|
getReadyState() {
|
|
if (!this.eventSource) return 'DISCONNECTED';
|
|
|
|
switch (this.eventSource.readyState) {
|
|
case EventSource.CONNECTING:
|
|
return 'CONNECTING';
|
|
case EventSource.OPEN:
|
|
return 'OPEN';
|
|
case EventSource.CLOSED:
|
|
return 'CLOSED';
|
|
default:
|
|
return 'UNKNOWN';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if connected
|
|
* @returns {boolean} True if connected
|
|
*/
|
|
isConnected() {
|
|
return this.eventSource && this.eventSource.readyState === EventSource.OPEN;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const sseClient = new SSEClient();
|
|
|
|
// Auto-initialize: connect to session from URL path
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Extract sessionId from URL path: /claude/ide/session/{sessionId}
|
|
const pathMatch = window.location.pathname.match(/\/claude\/ide\/session\/([^/]+)$/);
|
|
|
|
if (pathMatch && pathMatch[1]) {
|
|
const sessionId = decodeURIComponent(pathMatch[1]);
|
|
console.log('[SSEClient] Auto-connecting to session from URL:', sessionId);
|
|
sseClient.connect(sessionId);
|
|
}
|
|
});
|
|
|
|
// Make globally accessible
|
|
window.sseClient = sseClient;
|