Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified frontend/bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5",
"zod": "3.23.8"
"zod": "3.23.8",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
Expand Down
67 changes: 61 additions & 6 deletions frontend/src/components/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ interface SelectedFile {
uploadError?: string
preview?: string // URL for image preview
fileType?: FileType
uploadAborted?: boolean
}

export const getFileIcon = (fileType: FileType | string | undefined) => {
Expand Down Expand Up @@ -417,6 +418,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
const scrollPositionRef = useRef<number>(0)
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement | null>(null)
const uploadControllers = useRef<Map<string, AbortController>>(new Map())

const [showReferenceBox, setShowReferenceBox] = useState(false)
const [searchMode, setSearchMode] = useState<"citations" | "global">(
Expand Down Expand Up @@ -836,12 +838,16 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
setSelectedFiles((prev) =>
prev.map((f) =>
files.some((file) => file.id === f.id)
? { ...f, uploading: true, uploadError: undefined }
? { ...f, uploading: true, uploadError: undefined, uploadAborted: false }
: f,
),
)

const uploadPromises = files.map(async (selectedFile) => {
// Create AbortController for this file
const abortController = new AbortController()
uploadControllers.current.set(selectedFile.id, abortController)

try {
const formData = new FormData()
formData.append("attachment", selectedFile.file)
Expand All @@ -850,6 +856,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
{
method: "POST",
body: formData,
signal: abortController.signal,
},
)

Expand All @@ -874,6 +881,19 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
throw new Error("No document ID returned from upload")
}
} catch (error) {
// Check if the error is an AbortError
if (error instanceof Error && error.name === 'AbortError') {
// Handle aborted upload - don't show error toast
setSelectedFiles((prev) =>
prev.map((f) =>
f.id === selectedFile.id
? { ...f, uploading: false, uploadAborted: true }
: f,
),
)
return null
}

const errorMessage =
error instanceof Error ? error.message : "Upload failed"
setSelectedFiles((prev) =>
Expand All @@ -889,6 +909,8 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
})
return null
} finally {
// Clean up the controller
uploadControllers.current.delete(selectedFile.id)
setUploadingFilesCount((prev) => prev - 1)
}
})
Expand Down Expand Up @@ -980,6 +1002,25 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
const removeFile = useCallback(async (id: string) => {
const fileToRemove = selectedFiles.find((f) => f.id === id)

// If the file is currently uploading, abort the upload
if (fileToRemove?.uploading) {
const controller = uploadControllers.current.get(id)
if (controller) {
controller.abort()
uploadControllers.current.delete(id)
}

// Remove from UI immediately for aborted uploads
setSelectedFiles((prev) => {
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview)
}
return prev.filter((f) => f.id !== id)
})
setUploadingFilesCount((prev) => Math.max(0, prev - 1))
Copy link
Contributor

Choose a reason for hiding this comment

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

high

There's a bug here where uploadingFilesCount is decremented twice for a cancelled upload. It's decremented here, and also in the finally block of the uploadFiles function (line 914), which will execute after the upload promise is rejected due to the abort signal.

This can lead to an incorrect count and potential UI bugs. The finally block is a more reliable place to handle the decrement as it covers success, failure, and abort cases. I suggest removing this line to fix the double-decrement issue.

return
}
Comment on lines +1005 to +1022
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: double-decrement of uploadingFilesCount on abort

When canceling an in-flight upload, removeFile decrements the counter, and the upload promise finally decrements again. This can take the count negative and prematurely resolve send gating.

Fix by removing the early decrement and relying on the promise finally:

       if (controller) {
         controller.abort()
         uploadControllers.current.delete(id)
       }
       
       // Remove from UI immediately for aborted uploads
       setSelectedFiles((prev) => {
         if (fileToRemove?.preview) {
           URL.revokeObjectURL(fileToRemove.preview)
         }
         return prev.filter((f) => f.id !== id)
       })
-      setUploadingFilesCount((prev) => Math.max(0, prev - 1))
       return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If the file is currently uploading, abort the upload
if (fileToRemove?.uploading) {
const controller = uploadControllers.current.get(id)
if (controller) {
controller.abort()
uploadControllers.current.delete(id)
}
// Remove from UI immediately for aborted uploads
setSelectedFiles((prev) => {
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview)
}
return prev.filter((f) => f.id !== id)
})
setUploadingFilesCount((prev) => Math.max(0, prev - 1))
return
}
// If the file is currently uploading, abort the upload
if (fileToRemove?.uploading) {
const controller = uploadControllers.current.get(id)
if (controller) {
controller.abort()
uploadControllers.current.delete(id)
}
// Remove from UI immediately for aborted uploads
setSelectedFiles((prev) => {
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview)
}
return prev.filter((f) => f.id !== id)
})
return
}
🤖 Prompt for AI Agents
In frontend/src/components/ChatBox.tsx around lines 1005-1022, the abort branch
of removeFile calls setUploadingFilesCount decrement immediately and the upload
promise's finally also decrements, causing double-decrement and possible
negative counts; remove the early setUploadingFilesCount((prev) => Math.max(0,
prev - 1)) from this abort branch (still revoke preview URL, remove file from
UI, abort and delete the controller) and rely on the upload promise's finally to
decrement the uploadingFilesCount so the counter stays consistent.


// If the file has metadata with fileId (meaning it's already uploaded), delete it from the server
if (fileToRemove?.metadata?.fileId) {
try {
Expand Down Expand Up @@ -2213,6 +2254,12 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
.map((f) => f.preview)
.filter(Boolean) as string[]
cleanupPreviewUrls(previewUrls)

// Abort any ongoing uploads
uploadControllers.current.forEach((controller) => {
controller.abort()
})
uploadControllers.current.clear()
}
}, [])

Expand Down Expand Up @@ -2861,8 +2908,12 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
</div>
<button
onClick={() => removeFile(selectedFile.id)}
className="absolute top-1 right-1 bg-black bg-opacity-60 text-white rounded-full p-1 hover:bg-opacity-80 transition-opacity"
disabled={selectedFile.uploading}
className={`absolute top-1 right-1 rounded-full p-1 transition-opacity ${
selectedFile.uploading
? "bg-red-500 bg-opacity-80 text-white hover:bg-opacity-100"
: "bg-black bg-opacity-60 text-white hover:bg-opacity-80"
}`}
title={selectedFile.uploading ? "Cancel upload" : "Remove file"}
>
<X size={10} />
</button>
Expand Down Expand Up @@ -2917,8 +2968,12 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
)}
<button
onClick={() => removeFile(selectedFile.id)}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
disabled={selectedFile.uploading}
className={`transition-colors p-1 ${
selectedFile.uploading
? "text-red-500 hover:text-red-600"
: "text-gray-400 hover:text-red-500"
}`}
title={selectedFile.uploading ? "Cancel upload" : "Remove file"}
>
<X size={12} />
</button>
Expand All @@ -2931,7 +2986,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
{uploadingFilesCount > 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Loader2 size={12} className="animate-spin" />
Uploading files...
Uploading files... (click X to cancel)
</div>
)}
{selectedFiles.length >= MAX_ATTACHMENTS && (
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/components/__tests__/ChatBox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ChatBox } from '../ChatBox'
Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix TS6133: remove unused imports

Build fails due to unused renderHook and ChatBox.

Apply:

-import { renderHook, act } from '@testing-library/react'
+import { act } from '@testing-library/react'
-import { ChatBox } from '../ChatBox'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ChatBox } from '../ChatBox'
import { act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
🧰 Tools
🪛 GitHub Actions: TypeScript Build Check

[error] 1-1: bunx tsc -b: TS6133: 'renderHook' is declared but its value is never read.


[error] 3-3: bunx tsc -b: TS6133: 'ChatBox' is declared but its value is never read.

🤖 Prompt for AI Agents
In frontend/src/components/__tests__/ChatBox.test.tsx around lines 1-3, the test
file imports unused symbols causing TS6133; remove unused imports by deleting
renderHook from the first import and removing the ChatBox import (or import only
needed symbols) so the file only imports what it actually uses (e.g., keep act
and vitest imports) and update any references accordingly.


// Mock the necessary dependencies
vi.mock('@/utils/authFetch', () => ({
authFetch: vi.fn()
}))

vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({
toast: {
error: vi.fn(),
success: vi.fn()
}
})
}))

vi.mock('@/api', () => ({
api: {
files: {
delete: {
$post: vi.fn()
}
}
}
}))

describe('ChatBox Upload Cancellation', () => {
let mockAuthFetch: any

beforeEach(() => {
vi.clearAllMocks()
mockAuthFetch = vi.fn()
vi.doMock('@/utils/authFetch', () => ({
authFetch: mockAuthFetch
}))
})

it('should handle upload cancellation correctly', async () => {
// Create a mock AbortController
const abortController = new AbortController()

// Mock a file upload that can be aborted
const uploadPromise = new Promise((resolve, reject) => {
abortController.signal.addEventListener('abort', () => {
const error = new Error('Upload aborted')
error.name = 'AbortError'
reject(error)
})
})

mockAuthFetch.mockReturnValue(uploadPromise)

// Test that aborting the upload triggers the correct behavior
act(() => {
abortController.abort()
})

// Verify the upload was cancelled
await expect(uploadPromise).rejects.toThrow('Upload aborted')
})

it('should clean up upload controllers on component unmount', () => {
// This test would require more complex setup with actual component rendering
// For now, we'll just verify the concept
const controllers = new Map<string, AbortController>()
const controller1 = new AbortController()
const controller2 = new AbortController()

controllers.set('file1', controller1)
controllers.set('file2', controller2)

// Simulate cleanup
controllers.forEach((controller) => {
controller.abort()
})
controllers.clear()

expect(controllers.size).toBe(0)
expect(controller1.signal.aborted).toBe(true)
expect(controller2.signal.aborted).toBe(true)
})

it('should handle AbortError gracefully in upload completion', async () => {
const abortError = new Error('Request aborted')
abortError.name = 'AbortError'

// Mock the upload function to handle AbortError
const mockUploadFile = async (file: File, signal: AbortSignal) => {
if (signal.aborted) {
throw abortError
}
return { fileId: 'test-id', fileName: file.name }
}

const testFile = new File(['test'], 'test.txt', { type: 'text/plain' })
const abortController = new AbortController()

// Abort before upload
abortController.abort()

// Verify AbortError is thrown
await expect(mockUploadFile(testFile, abortController.signal)).rejects.toThrow('Request aborted')
})
})
Comment on lines +29 to +106
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

These tests verify the concepts of upload cancellation (like how AbortController works) but they don't actually test the ChatBox component's implementation. The component itself is imported but never rendered or tested.

To make these tests more effective and ensure the cancellation feature works as expected from a user's perspective, I recommend using @testing-library/react to render the ChatBox component, simulate file selection, trigger the cancellation by clicking the 'X' button, and then assert that the upload was aborted and the UI was updated correctly.

Here's an example of how such a test could be structured:

import { render, screen, fireEvent, waitFor } from '@testing-library/react';

it('should cancel an ongoing upload when the cancel button is clicked', async () => {
  // 1. Mock authFetch to simulate a pending upload that can be aborted
  const abortSpy = vi.fn();
  mockAuthFetch.mockImplementation((url, options) => {
    options.signal.addEventListener('abort', () => {
      abortSpy();
      const error = new Error('Upload aborted');
      error.name = 'AbortError';
      // This would normally be handled by fetch, but for test we reject a promise
    });
    // Return a promise that never resolves to simulate a long upload
    return new Promise(() => {});
  });

  // 2. Render the ChatBox component with necessary props
  render(<ChatBox {...mockProps} />);

  // 3. Simulate file selection
  const fileInput = screen.getByTitle(/Attach files/); // Use a more accessible selector
  const file = new File(['content'], 'test.png', { type: 'image/png' });
  await act(async () => {
    fireEvent.change(fileInput, { target: { files: [file] } });
  });

  // 4. Find and click the cancel button
  const cancelButton = await screen.findByTitle('Cancel upload');
  fireEvent.click(cancelButton);

  // 5. Assert that the abort function was called and the file is removed from UI
  await waitFor(() => {
    expect(abortSpy).toHaveBeenCalled();
    expect(screen.queryByText('test.png')).not.toBeInTheDocument();
  });
});

118 changes: 118 additions & 0 deletions frontend/src/store/__tests__/useUploadProgressStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix TS6133: remove unused vi import

Build fails due to unused import.

Apply:

-import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { describe, it, expect, beforeEach } from 'vitest'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
🧰 Tools
🪛 GitHub Actions: TypeScript Build Check

[error] 1-1: bunx tsc -b: TS6133: 'vi' is declared but its value is never read.

🤖 Prompt for AI Agents
In frontend/src/store/__tests__/useUploadProgressStore.test.ts around line 1,
the import list includes an unused `vi` from 'vitest' causing TS6133; remove
`vi` from the import statement (or replace the import with only the used
symbols) so the file imports only describe, it, expect, beforeEach to eliminate
the unused-import TypeScript error.

import { useUploadProgress } from '../useUploadProgressStore'

// Mock File API for testing
global.File = class MockFile {
name: string
size: number
type: string
lastModified: number

constructor(bits: any[], name: string, options?: { type?: string }) {
this.name = name
this.type = options?.type || 'text/plain'
this.size = bits.reduce((acc, bit) => acc + (typeof bit === 'string' ? bit.length : 0), 0)
this.lastModified = Date.now()
}
} as any

describe('useUploadProgressStore', () => {
beforeEach(() => {
// Reset the store before each test by getting the state and clearing it
const store = useUploadProgress.getState()
if (store.currentUpload) {
store.finishUpload(store.currentUpload.id)
}
})

it('should start upload with abort controller', () => {
const store = useUploadProgress.getState()

const files = [
{ file: new File(['test'], 'test.txt'), id: 'file1' }
]

const result = store.startUpload('test-collection', files, 1, true)

expect(result.uploadId).toBeDefined()
expect(result.abortController).toBeInstanceOf(AbortController)
expect(useUploadProgress.getState().currentUpload?.id).toBe(result.uploadId)
})

it('should cancel upload and abort controller', () => {
const store = useUploadProgress.getState()

const files = [
{ file: new File(['test'], 'test.txt'), id: 'file1' }
]

const { uploadId, abortController } = store.startUpload('test-collection', files, 1, true)

// Verify controller is not aborted initially
expect(abortController.signal.aborted).toBe(false)

// Cancel the upload
store.cancelUpload(uploadId)

// Verify controller is aborted and upload is removed
expect(abortController.signal.aborted).toBe(true)
expect(useUploadProgress.getState().currentUpload).toBeNull()
})

it('should remove progress', () => {
const store = useUploadProgress.getState()

const files = [
{ file: new File(['test'], 'test.txt'), id: 'file1' }
]

const { uploadId } = store.startUpload('test-collection', files, 1, true)

// Verify upload exists
expect(useUploadProgress.getState().currentUpload?.id).toBe(uploadId)

// Remove progress
store.removeProgress(uploadId)

// Verify upload is removed
expect(useUploadProgress.getState().currentUpload).toBeNull()
})

it('should handle file status updates', () => {
const store = useUploadProgress.getState()

const files = [
{ file: new File(['test'], 'test.txt'), id: 'file1' }
]

const { uploadId } = store.startUpload('test-collection', files, 1, true)

// Update file status to uploading
store.updateFileStatus(uploadId, 'test.txt', 'file1', 'uploading')

let upload = useUploadProgress.getState().currentUpload
expect(upload?.files[0].status).toBe('uploading')

// Update file status to uploaded
store.updateFileStatus(uploadId, 'test.txt', 'file1', 'uploaded')

upload = useUploadProgress.getState().currentUpload
expect(upload?.files[0].status).toBe('uploaded')
})

it('should handle abort controller cleanup', () => {
const store = useUploadProgress.getState()

const files = [
{ file: new File(['test'], 'test.txt'), id: 'file1' }
]

const { abortController } = store.startUpload('test-collection', files, 1, true)

// Manually abort the controller
abortController.abort()

// Verify the signal is aborted
expect(abortController.signal.aborted).toBe(true)
})
})
Loading
Loading