⬆️ (fix): redo after rebase

(front) add e2e pasting an attachment from clipboard

add test e2e paste attachment
This commit is contained in:
natoromano
2026-02-13 11:53:17 +01:00
committed by Eléonore Voisin
parent 09dceb508d
commit 9d16460777
4 changed files with 152 additions and 67 deletions
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(front) allow pasting an attachment from clipboard
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
@@ -213,39 +213,56 @@ export const InputChat = ({
}
}, [status, input]);
const handleFilesAccepted = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const dt = new DataTransfer();
const validateAndAddFiles = useCallback(
(filesToAdd: File[]) => {
const acceptedFiles: File[] = [];
const rejectedFiles: File[] = [];
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
filesToAdd.forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file);
}
// Add new files (avoiding duplicates)
acceptedFiles.forEach((f) => {
const isDuplicate = Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
);
if (!isDuplicate) {
dt.items.add(f);
}
});
return dt.files;
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
// Add new files (avoiding duplicates)
acceptedFiles.forEach((f) => {
const isDuplicate = Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
);
if (!isDuplicate) {
dt.items.add(f);
}
});
return dt.files;
});
}
},
[setFiles],
[isFileAccepted, showToastError, setFiles],
);
const { isDragActive } = useFileDragDrop({
enabled: fileUploadEnabled,
isFileAccepted,
onFilesAccepted: handleFilesAccepted,
onFilesAccepted: validateAndAddFiles,
onFilesRejected: () => showToastError(),
});
@@ -293,6 +310,41 @@ export const InputChat = ({
[],
);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!fileUploadEnabled) {
return;
}
const clipboardData = e.clipboardData;
if (!clipboardData) {
return;
}
// Due to browser limitations, only one file can be pasted at a time
// Check files first (for files from file system)
let file: File | null = null;
if (clipboardData.files && clipboardData.files.length > 0) {
file = clipboardData.files[0];
} else if (clipboardData.items) {
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.kind === 'file') {
file = item.getAsFile();
break;
}
}
}
if (file) {
e.preventDefault();
validateAndAddFiles([file]);
}
},
[fileUploadEnabled, validateAndAddFiles],
);
const handleAttachClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
@@ -309,46 +361,10 @@ export const InputChat = ({
return;
}
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
acceptedFiles.forEach((f) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
return dt.files;
});
}
validateAndAddFiles(Array.from(fileList));
e.target.value = '';
},
[isFileAccepted, showToastError, setFiles],
[validateAndAddFiles],
);
const handleAttachmentRemove = useCallback(
@@ -443,6 +459,7 @@ export const InputChat = ({
name="inputchat-textarea"
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onPaste={handlePaste}
disabled={isInputDisabled}
rows={1}
style={textareaStyle}
@@ -45,11 +45,6 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
$background="var(--c--contextuals--background--surface--tertiary)"
$color="var(--c--contextuals--content--semantic--neutral--secondary)"
$padding={{ all: 'sm' }}
$radius="8px"
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
>
<Loader />
<Text $variation="600" $size="md">
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/home/');
});
@@ -69,4 +71,71 @@ test.describe('Chat page', () => {
await chatHistoryLink.click();
await expect(messageContent).toBeVisible();
});
test('the user can paste a document into the chat input', async ({ page }) => {
await overrideConfig(page, {
FEATURE_FLAGS: {
'document-upload': 'enabled',
'web-search': 'enabled',
},
});
await page.goto('/');
const chatInput = page.getByRole('textbox', {
name: 'Enter your message or a',
});
await expect(chatInput).toBeVisible();
await chatInput.click();
// Create a test file content
const fileContent = 'Test document content for paste';
const fileName = 'test-document.txt';
const fileType = 'text/plain';
// Simulate paste event with file
await page.evaluate(
({ content, name, type }) => {
const textarea = document.querySelector(
'textarea[name="inputchat-textarea"]',
) as HTMLTextAreaElement;
if (!textarea) return;
// Create a File object
const file = new File([content], name, { type });
// Create a DataTransfer object to simulate clipboard
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
// Create a paste event - ClipboardEvent constructor doesn't accept clipboardData
// so we create a regular Event and add clipboardData property
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
}) as unknown as ClipboardEvent;
// Define clipboardData property to make it accessible
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
files: dataTransfer.files,
items: dataTransfer.items,
types: Array.from(dataTransfer.types),
getData: () => '',
setData: () => {},
},
writable: false,
configurable: true,
});
textarea.dispatchEvent(pasteEvent);
},
{ content: fileContent, name: fileName, type: fileType },
);
// Wait for the file to be processed and appear in the attachment list
// The attachment should be visible with the file name
const attachment = page.getByText(fileName, { exact: false }).first();
await expect(attachment).toBeVisible({ timeout: 5000 });
});
});