mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-05 22:17:42 +00:00
fix: preserve pending permission requests across WebSocket reconnections (#462)
* fix: preserve pending permission requests across WebSocket reconnections - Store WebSocketWriter reference in active sessions for reconnection - Add reconnectSessionWriter() to swap writer when client reconnects - Add getPendingApprovalsForSession() to query pending permissions by session - Add get-pending-permissions WebSocket message handler on server - Add pending-permissions-response handler on frontend - Query pending permissions on WebSocket reconnect and session change - Reconnect SDK output writer when client resumes an active session * fix: address CodeRabbit review feedback for websocket-permission PR - Use consistent session matching in pending-permissions-response handler, checking both currentSessionId and selectedSession.id (matching the session-status handler pattern) - Guard get-pending-permissions with isClaudeSDKSessionActive check to prevent returning permissions for inactive sessions
This commit is contained in:
@@ -34,7 +34,7 @@ function createRequestId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function waitForToolApproval(requestId, options = {}) {
|
function waitForToolApproval(requestId, options = {}) {
|
||||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -78,9 +78,14 @@ function waitForToolApproval(requestId, options = {}) {
|
|||||||
signal.addEventListener('abort', abortHandler, { once: true });
|
signal.addEventListener('abort', abortHandler, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingToolApprovals.set(requestId, (decision) => {
|
const resolver = (decision) => {
|
||||||
finalize(decision);
|
finalize(decision);
|
||||||
});
|
};
|
||||||
|
// Attach metadata for getPendingApprovalsForSession lookup
|
||||||
|
if (metadata) {
|
||||||
|
Object.assign(resolver, metadata);
|
||||||
|
}
|
||||||
|
pendingToolApprovals.set(requestId, resolver);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,13 +214,14 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
||||||
* @param {string} tempDir - Temp directory for cleanup
|
* @param {string} tempDir - Temp directory for cleanup
|
||||||
*/
|
*/
|
||||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
|
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
|
||||||
activeSessions.set(sessionId, {
|
activeSessions.set(sessionId, {
|
||||||
instance: queryInstance,
|
instance: queryInstance,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
tempImagePaths,
|
tempImagePaths,
|
||||||
tempDir
|
tempDir,
|
||||||
|
writer
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +518,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
const decision = await waitForToolApproval(requestId, {
|
const decision = await waitForToolApproval(requestId, {
|
||||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||||
signal: context?.signal,
|
signal: context?.signal,
|
||||||
|
metadata: {
|
||||||
|
_sessionId: capturedSessionId || sessionId || null,
|
||||||
|
_toolName: toolName,
|
||||||
|
_input: input,
|
||||||
|
_receivedAt: new Date(),
|
||||||
|
},
|
||||||
onCancel: (reason) => {
|
onCancel: (reason) => {
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'claude-permission-cancelled',
|
type: 'claude-permission-cancelled',
|
||||||
@@ -562,7 +574,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
|
|
||||||
// Track the query instance for abort capability
|
// Track the query instance for abort capability
|
||||||
if (capturedSessionId) {
|
if (capturedSessionId) {
|
||||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process streaming messages
|
// Process streaming messages
|
||||||
@@ -572,7 +584,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
if (message.session_id && !capturedSessionId) {
|
if (message.session_id && !capturedSessionId) {
|
||||||
|
|
||||||
capturedSessionId = message.session_id;
|
capturedSessionId = message.session_id;
|
||||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||||
|
|
||||||
// Set session ID on writer
|
// Set session ID on writer
|
||||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
@@ -712,11 +724,50 @@ function getActiveClaudeSDKSessions() {
|
|||||||
return getAllSessions();
|
return getAllSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending tool approvals for a specific session.
|
||||||
|
* @param {string} sessionId - The session ID
|
||||||
|
* @returns {Array} Array of pending permission request objects
|
||||||
|
*/
|
||||||
|
function getPendingApprovalsForSession(sessionId) {
|
||||||
|
const pending = [];
|
||||||
|
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
||||||
|
if (resolver._sessionId === sessionId) {
|
||||||
|
pending.push({
|
||||||
|
requestId,
|
||||||
|
toolName: resolver._toolName || 'UnknownTool',
|
||||||
|
input: resolver._input,
|
||||||
|
context: resolver._context,
|
||||||
|
sessionId,
|
||||||
|
receivedAt: resolver._receivedAt || new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
||||||
|
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
||||||
|
* @param {string} sessionId - The session ID
|
||||||
|
* @param {Object} newRawWs - The new raw WebSocket connection
|
||||||
|
* @returns {boolean} True if writer was successfully reconnected
|
||||||
|
*/
|
||||||
|
function reconnectSessionWriter(sessionId, newRawWs) {
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session?.writer?.updateWebSocket) return false;
|
||||||
|
session.writer.updateWebSocket(newRawWs);
|
||||||
|
console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Export public API
|
// Export public API
|
||||||
export {
|
export {
|
||||||
queryClaudeSDK,
|
queryClaudeSDK,
|
||||||
abortClaudeSDKSession,
|
abortClaudeSDKSession,
|
||||||
isClaudeSDKSessionActive,
|
isClaudeSDKSessionActive,
|
||||||
getActiveClaudeSDKSessions,
|
getActiveClaudeSDKSessions,
|
||||||
resolveToolApproval
|
resolveToolApproval,
|
||||||
|
getPendingApprovalsForSession,
|
||||||
|
reconnectSessionWriter
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import fetch from 'node-fetch';
|
|||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
|
||||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||||
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
||||||
@@ -1380,6 +1380,10 @@ class WebSocketWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateWebSocket(newRawWs) {
|
||||||
|
this.ws = newRawWs;
|
||||||
|
}
|
||||||
|
|
||||||
setSessionId(sessionId) {
|
setSessionId(sessionId) {
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
}
|
}
|
||||||
@@ -1494,6 +1498,11 @@ function handleChatConnection(ws) {
|
|||||||
} else {
|
} else {
|
||||||
// Use Claude Agents SDK
|
// Use Claude Agents SDK
|
||||||
isActive = isClaudeSDKSessionActive(sessionId);
|
isActive = isClaudeSDKSessionActive(sessionId);
|
||||||
|
if (isActive) {
|
||||||
|
// Reconnect the session's writer to the new WebSocket so
|
||||||
|
// subsequent SDK output flows to the refreshed client.
|
||||||
|
reconnectSessionWriter(sessionId, ws);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.send({
|
writer.send({
|
||||||
@@ -1502,6 +1511,17 @@ function handleChatConnection(ws) {
|
|||||||
provider,
|
provider,
|
||||||
isProcessing: isActive
|
isProcessing: isActive
|
||||||
});
|
});
|
||||||
|
} else if (data.type === 'get-pending-permissions') {
|
||||||
|
// Return pending permission requests for a session
|
||||||
|
const sessionId = data.sessionId;
|
||||||
|
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
||||||
|
const pending = getPendingApprovalsForSession(sessionId);
|
||||||
|
writer.send({
|
||||||
|
type: 'pending-permissions-response',
|
||||||
|
sessionId,
|
||||||
|
data: pending
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (data.type === 'get-active-sessions') {
|
} else if (data.type === 'get-active-sessions') {
|
||||||
// Get all currently active sessions
|
// Get all currently active sessions
|
||||||
const activeSessions = {
|
const activeSessions = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ export default function AppContent() {
|
|||||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||||
const { ws, sendMessage, latestMessage } = useWebSocket();
|
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
||||||
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeSessions,
|
activeSessions,
|
||||||
@@ -71,6 +72,24 @@ export default function AppContent() {
|
|||||||
};
|
};
|
||||||
}, [openSettings]);
|
}, [openSettings]);
|
||||||
|
|
||||||
|
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
||||||
|
useEffect(() => {
|
||||||
|
const isReconnect = isConnected && !wasConnectedRef.current;
|
||||||
|
|
||||||
|
if (isReconnect) {
|
||||||
|
wasConnectedRef.current = true;
|
||||||
|
} else if (!isConnected) {
|
||||||
|
wasConnectedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnected && selectedSession?.id) {
|
||||||
|
sendMessage({
|
||||||
|
type: 'get-pending-permissions',
|
||||||
|
sessionId: selectedSession.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isConnected, selectedSession?.id, sendMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex bg-background">
|
<div className="fixed inset-0 flex bg-background">
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
|
|||||||
@@ -1080,6 +1080,19 @@ export function useChatRealtimeHandlers({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'pending-permissions-response': {
|
||||||
|
// Server returned pending permissions for this session
|
||||||
|
const permSessionId = latestMessage.sessionId;
|
||||||
|
const isCurrentPermSession =
|
||||||
|
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
|
||||||
|
if (permSessionId && !isCurrentPermSession) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const serverRequests = latestMessage.data || [];
|
||||||
|
setPendingPermissionRequests(serverRequests);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user