Conversation
|
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. |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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.
| value={renameValue} | ||
| onChange={(e) => setRenameValue(e.target.value)} | ||
| onBlur={() => handleRenameChat(chat.id)} | ||
| onKeyDown={(e) => { if (e.key === 'Escape') { setRenamingChatId(null); } }} |
There was a problem hiding this comment.
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.
| onKeyDown={(e) => { if (e.key === 'Escape') { setRenamingChatId(null); } }} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Escape') { | |
| setRenamingChatId(null); | |
| setRenameValue(chat.title); | |
| } | |
| }} |
| @@ -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); | |||
There was a problem hiding this comment.
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.
| @@ -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); | |||
| } | |||
There was a problem hiding this comment.
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.
| 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} |
There was a problem hiding this comment.
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.
| maxLength={60} |
| @@ -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 | |||
There was a problem hiding this comment.
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.
| // 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, |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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.
| <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> |
There was a problem hiding this comment.
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.
| <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> |
| <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> |
There was a problem hiding this comment.
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.
This pull request is for UI changes W2/23-30/26
Chat Renaming Feature:
titlefield to theIChatinterface andchatSchemainserver/models/User.tsso all chats have a title proprenameChatcontroller inserver/controllers/chatController.tsto handle PATCH requests for renaming chats/chat/rename/:chat_idendpoint inserver/routes/chatRoutes.tssrc/pages/Chat.tsxto allow users to rename chats (inline)File Upload Improvements:
UI Enhancements:
Bug Fixes: