Skip to content

Pdf processing progress#20

Open
ChenMel27 wants to merge 5 commits intomainfrom
pdf-processing-progress
Open

Pdf processing progress#20
ChenMel27 wants to merge 5 commits intomainfrom
pdf-processing-progress

Conversation

@ChenMel27
Copy link
Collaborator

This pull request is for UI changes W2/23-30/26

Chat Renaming Feature:

  • Added a title field to the IChat interface and chatSchema in server/models/User.ts so all chats have a title prop
  • Implemented the renameChat controller in server/controllers/chatController.ts to handle PATCH requests for renaming chats
  • Registered the new /chat/rename/:chat_id endpoint in server/routes/chatRoutes.ts
  • Updated frontend src/pages/Chat.tsx to allow users to rename chats (inline)

File Upload Improvements:

  • Allow users to cancel PDF uploads midway

UI Enhancements:

  • Add loading spinner and messaging on the profile page
  • Updated file message styling (clear differentiation between user and AI messages)

Bug Fixes:

  • New chats are now initialized with a default title in backend ("NEW CHAT").

@VinhPham25
Copy link
Collaborator

All the changes look good besides the cancel button. I tested out the cancel functionality, and although the process is cancelled in the actual dashboard, the api never actually resets. You can see this in the terminal of the ai-server. When you click cancel, the api doesn't actually fallback or end processing, but instead just sits there. When the API gets another signal, for example switching chats or uploading the pdf again, it continues the same processing.

Copilot AI review requested due to automatic review settings February 25, 2026 16:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements UI improvements for weeks 2/23-30/26, focusing on chat management features, file upload enhancements, and improved user experience through loading states and better visual differentiation.

Changes:

  • Added inline chat renaming functionality with dedicated rename button and input field
  • Implemented PDF upload cancellation with server-side abort controller support
  • Enhanced loading states with animated spinner and progress messages on the profile page

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
src/pages/ProfilePage.tsx Added animated loading spinner and progress messages for better UX during profile loading
src/pages/Chat.tsx Implemented inline chat renaming UI, PDF upload cancellation button, and improved file message styling with distinct colors for user vs AI messages
server/routes/uploadRoutes.ts Added new /cancel-processing endpoint to support upload cancellation
server/routes/chatRoutes.ts Registered new /rename/:chat_id PATCH endpoint for chat renaming
server/models/User.ts Added title field to IChat interface and schema with default value 'New Chat'
server/controllers/uploadController.ts Implemented upload cancellation with AbortController, client disconnect handling, and progress interval management
server/controllers/chatController.ts Added renameChat controller with validation, and updated chat creation to include default titles

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Optimistic update
setChats(prev => prev.map(c => c.id === chatId ? { ...c, title: trimmed } : c));
try {
await axios.patch(`http://localhost:4000/chat/rename/${chatId}`, { title: trimmed }, { withCredentials: true });
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded URLs 'http://localhost:4000' and 'http://localhost:8000' should be replaced with environment variables. The codebase already uses environment variables for configuration (see server/utils/axiosConfig.ts which uses PYTHON_SERVICE_URL). Consider creating environment variables like BACKEND_URL or API_BASE_URL for consistency and to support different deployment environments.

Copilot uses AI. Check for mistakes.
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRenameChat(chat.id)}
onKeyDown={(e) => { if (e.key === 'Escape') { setRenamingChatId(null); } }}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user presses Escape (line 1620), the rename mode is cancelled by setting renamingChatId to null, but the renameValue state is not reset. If the user enters text, presses Escape, then clicks the rename button again, the input will show the previous (cancelled) text instead of the current chat title. Consider also resetting renameValue when Escape is pressed.

Suggested change
onKeyDown={(e) => { if (e.key === 'Escape') { setRenamingChatId(null); } }}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setRenamingChatId(null);
setRenameValue(chat.title);
}
}}

Copilot uses AI. Check for mistakes.
Comment on lines 27 to 52
@@ -41,12 +46,51 @@ export const processPdf: RequestHandler = async (req, res) => {
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering if present
res.flushHeaders();

// Create an AbortController to cancel the request to the AI server
const abortController = new AbortController();
const uploadId = String(++uploadCounter);
activeUploads.set(uploadId, abortController);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The uploadCounter variable (line 27) is shared across all requests and uses a simple increment operation (line 51). In a multi-threaded environment or under high concurrency, this could lead to race conditions where two requests get the same uploadId. While Node.js is single-threaded for JavaScript execution, consider using a more robust ID generation approach (e.g., UUID) to ensure uniqueness, especially if this service scales horizontally or runs with clustering.

Copilot uses AI. Check for mistakes.
Comment on lines 124 to 203
@@ -87,24 +131,49 @@ export const processPdf: RequestHandler = async (req, res) => {
}
}, 1500);

// Send to processing server
// Send to processing server, passing the abort signal so we can cancel mid-flight
const response = await axios.post('http://localhost:8000/process-pdf', formData, {
headers: {
...formData.getHeaders()
},
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
signal: abortController.signal,
});

clearInterval(progressInterval);
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}

// If client already disconnected while we were waiting, don't bother sending events
if (clientDisconnected) {
console.log(`[Upload ${uploadId}] AI server responded but client already disconnected — discarding result`);
return;
}

console.log('Received response from processing server');
console.log(`[Upload ${uploadId}] Received response from processing server`);

sendEvent({ type: 'progress', percent: 95, message: 'Finalizing your learning path...' });
sendEvent({ type: 'progress', percent: 100, message: 'Processing complete!' });
sendEvent({ type: 'complete', data: response.data });
res.end();
} catch (error) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}

// If the request was cancelled (client disconnect or explicit cancel), don't send error events
if (axios.isCancel(error) || abortController.signal.aborted) {
console.log(`[Upload ${uploadId}] Request was cancelled`);
if (!clientDisconnected) {
sendEvent({ type: 'error', error: 'Processing was cancelled' });
res.end();
}
return;
}

console.error('Detailed error information:');
if (axios.isAxiosError(error)) {
console.error('Axios Error:', {
@@ -126,6 +195,33 @@ export const processPdf: RequestHandler = async (req, res) => {
details: error instanceof Error ? error.message : 'Unknown error'
});
}
res.end();
if (!clientDisconnected) {
res.end();
}
} finally {
activeUploads.delete(uploadId);
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The progressInterval is started on line 124 but is only cleared in specific paths (lines 144-147 on success, lines 162-165 in catch block). If the function returns early (e.g., line 98 or 152), the interval will continue running indefinitely, causing a memory leak. The interval cleanup in the finally block only clears the Map entry, not the interval itself. Ensure the interval is always cleared by moving the clearInterval logic to the finally block.

Copilot uses AI. Check for mistakes.
onBlur={() => handleRenameChat(chat.id)}
onKeyDown={(e) => { if (e.key === 'Escape') { setRenamingChatId(null); } }}
className="w-full bg-slate-700 text-slate-200 text-sm rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-teal-400"
maxLength={60}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input has a maxLength of 60 characters on the client side (line 1622), but there's no corresponding validation on the server side (server/controllers/chatController.ts lines 311-314). A malicious client could bypass the client-side validation and send a longer title. Consider adding a maximum length check on the server to enforce this constraint consistently.

Suggested change
maxLength={60}

Copilot uses AI. Check for mistakes.
Comment on lines 86 to 227
@@ -77,7 +121,7 @@ export const processPdf: RequestHandler = async (req, res) => {

// Simulate incremental progress while waiting for the Python server
let currentProgress = 15;
const progressInterval = setInterval(() => {
progressInterval = setInterval(() => {
if (currentProgress < 90) {
// Gradually slow down as we approach 90%
const increment = Math.max(1, (90 - currentProgress) * 0.08);
@@ -87,24 +131,49 @@ export const processPdf: RequestHandler = async (req, res) => {
}
}, 1500);

// Send to processing server
// Send to processing server, passing the abort signal so we can cancel mid-flight
const response = await axios.post('http://localhost:8000/process-pdf', formData, {
headers: {
...formData.getHeaders()
},
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
signal: abortController.signal,
});

clearInterval(progressInterval);
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}

// If client already disconnected while we were waiting, don't bother sending events
if (clientDisconnected) {
console.log(`[Upload ${uploadId}] AI server responded but client already disconnected — discarding result`);
return;
}

console.log('Received response from processing server');
console.log(`[Upload ${uploadId}] Received response from processing server`);

sendEvent({ type: 'progress', percent: 95, message: 'Finalizing your learning path...' });
sendEvent({ type: 'progress', percent: 100, message: 'Processing complete!' });
sendEvent({ type: 'complete', data: response.data });
res.end();
} catch (error) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}

// If the request was cancelled (client disconnect or explicit cancel), don't send error events
if (axios.isCancel(error) || abortController.signal.aborted) {
console.log(`[Upload ${uploadId}] Request was cancelled`);
if (!clientDisconnected) {
sendEvent({ type: 'error', error: 'Processing was cancelled' });
res.end();
}
return;
}

console.error('Detailed error information:');
if (axios.isAxiosError(error)) {
console.error('Axios Error:', {
@@ -126,6 +195,33 @@ export const processPdf: RequestHandler = async (req, res) => {
details: error instanceof Error ? error.message : 'Unknown error'
});
}
res.end();
if (!clientDisconnected) {
res.end();
}
} finally {
activeUploads.delete(uploadId);
}
};

// Cancel endpoint: explicitly aborts all active upload processing
export const cancelProcessing: RequestHandler = async (req, res) => {
console.log(`[Cancel] Cancelling ${activeUploads.size} active upload(s)`);

// Abort all active uploads
for (const [id, controller] of activeUploads) {
console.log(`[Cancel] Aborting upload ${id}`);
controller.abort();
activeUploads.delete(id);
}
}; No newline at end of file

// Also try to tell the AI server to cancel (fire-and-forget)
try {
await axios.post('http://localhost:8000/cancel-processing', {}, { timeout: 3000 });
console.log('[Cancel] AI server cancel endpoint called successfully');
} catch {
// AI server may not have a cancel endpoint — that's fine
console.log('[Cancel] AI server cancel endpoint not available (this is OK)');
}

res.json({ success: true, message: 'Processing cancelled' });
}; No newline at end of file
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded URLs 'http://localhost:4000' (line 1724) and 'http://localhost:8000' (lines 86, 135, 219) should be replaced with environment variables for consistency with the rest of the codebase. The file server/utils/axiosConfig.ts already demonstrates this pattern by using process.env.PYTHON_SERVICE_URL.

Copilot uses AI. Check for mistakes.
Comment on lines 119 to 128
// If graph_id is provided, update it
if (graph_id) {
if (graph_id && chat) {
chat.graph_id = graph_id;
}

// Only add message if text is provided (allows for graph_id only updates)
if (text) {
if (text && chat) {
const newMessage = {
sender,
text,
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checks if (graph_id && chat) (line 120) and if (text && chat) (line 125) are redundant. After line 116, chat is guaranteed to be defined since it either already existed or was just created and assigned. The chat check adds no value and can be simplified to just if (graph_id) and if (text).

Copilot uses AI. Check for mistakes.
Comment on lines 207 to 227
export const cancelProcessing: RequestHandler = async (req, res) => {
console.log(`[Cancel] Cancelling ${activeUploads.size} active upload(s)`);

// Abort all active uploads
for (const [id, controller] of activeUploads) {
console.log(`[Cancel] Aborting upload ${id}`);
controller.abort();
activeUploads.delete(id);
}
}; No newline at end of file

// Also try to tell the AI server to cancel (fire-and-forget)
try {
await axios.post('http://localhost:8000/cancel-processing', {}, { timeout: 3000 });
console.log('[Cancel] AI server cancel endpoint called successfully');
} catch {
// AI server may not have a cancel endpoint — that's fine
console.log('[Cancel] AI server cancel endpoint not available (this is OK)');
}

res.json({ success: true, message: 'Processing cancelled' });
}; No newline at end of file
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancelProcessing endpoint lacks authentication and authorization checks. Any unauthenticated user can call this endpoint and cancel all active uploads for all users, potentially disrupting the service. This endpoint should verify that the request is from an authenticated user (checking req.session) before allowing cancellation.

Copilot uses AI. Check for mistakes.
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-teal-500" />
</div>
<p className="text-lg font-medium text-slate-700">Loading your profile</p>
<p className="mt-1 text-sm text-slate-400 animate-pulse">Gathering your progress data...</p>
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The animate-pulse class is applied to a text element (line 471), but this creates a pulsing opacity effect which may reduce readability. Consider using a different animation approach or removing the animation for text content, reserving pulse animations for loading indicators only.

Suggested change
<p className="mt-1 text-sm text-slate-400 animate-pulse">Gathering your progress data...</p>
<p className="mt-1 text-sm text-slate-400">Gathering your progress data...</p>

Copilot uses AI. Check for mistakes.
Comment on lines +1718 to +1738
<button
onClick={async () => {
// 1. Abort the client-side fetch (closes SSE connection)
uploadAbortRef.current?.abort();
// 2. Tell the server to cancel the AI processing
try {
await fetch('http://localhost:4000/upload/cancel-processing', { method: 'POST' });
} catch {
// Best-effort — server may already have cleaned up
}
// 3. Reset client UI state
setIsProcessingFile(false);
setSelectedFile(null);
setUploadProgress(0);
setProgressMessage('');
if (fileInputRef.current) fileInputRef.current.value = '';
}}
className="mt-6 px-4 py-2 text-sm text-red-500 border border-red-300 rounded-lg hover:bg-red-50 transition-colors duration-150"
>
Cancel
</button>
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user clicks the Cancel button, the client makes a fetch request to '/upload/cancel-processing' which cancels ALL active uploads across all users (see server/controllers/uploadController.ts lines 207-215). This means clicking cancel will disrupt other users' uploads. The cancel logic should only abort the current user's upload, not all uploads globally. Consider passing the specific uploadId or using session-based tracking.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants