From 419987d8900eb1846fdc680d8f8aaee9ffd0236a Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 15:57:30 +0200 Subject: [PATCH] feat(chat): colle les fichiers du presse-papiers via Ctrl+V dans le composer (#181) --- crates/application/src/chat_attachments.rs | 15 ++ crates/application/tests/chat_attachments.rs | 105 +++++++++++- .../agents/CustomAgentChatView.test.tsx | 156 ++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 23 ++- 4 files changed, 296 insertions(+), 3 deletions(-) diff --git a/crates/application/src/chat_attachments.rs b/crates/application/src/chat_attachments.rs index 7eefbffa..f8855967 100644 --- a/crates/application/src/chat_attachments.rs +++ b/crates/application/src/chat_attachments.rs @@ -110,6 +110,7 @@ impl ImportChatAttachments { .await? } ImportChatAttachmentSource::Bytes { bytes, .. } => { + validate_attachment_size(bytes.len() as u64)?; self.store .import_from_bytes( &input.project.root, @@ -174,6 +175,20 @@ fn validate_attachment_filename(filename: &str) -> Result<(), AppError> { Ok(()) } +fn validate_attachment_size(size_bytes: u64) -> Result<(), AppError> { + if size_bytes == 0 { + return Err(AppError::Invalid( + "chat attachment must not be empty".to_owned(), + )); + } + if size_bytes > CHAT_ATTACHMENT_MAX_BYTES { + return Err(AppError::Invalid(format!( + "chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes" + ))); + } + Ok(()) +} + fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result { let mime = raw .map(str::trim) diff --git a/crates/application/tests/chat_attachments.rs b/crates/application/tests/chat_attachments.rs index b9a78009..54010961 100644 --- a/crates/application/tests/chat_attachments.rs +++ b/crates/application/tests/chat_attachments.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use application::{ ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments, - ImportChatAttachmentsInput, + ImportChatAttachmentsInput, CHAT_ATTACHMENT_MAX_BYTES, }; #[derive(Default)] @@ -220,6 +220,109 @@ async fn import_chat_attachments_routes_clipboard_bytes_to_store_port() { assert_eq!(calls[0].1.filename, "clipboard.png"); } +#[tokio::test] +async fn import_chat_attachments_accepts_generic_clipboard_file_bytes() { + let store = Arc::new(FakeChatAttachments::default()); + let usecase = ImportChatAttachments::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + + let output = usecase + .execute(ImportChatAttachmentsInput { + project: project(), + agent_id: AgentId::new_random(), + session_id: SessionId::new_random(), + attachments: vec![ImportChatAttachmentItem { + source: ImportChatAttachmentSource::Bytes { + filename: "clipboard.pdf".to_owned(), + bytes: b"%PDF-1.7".to_vec(), + }, + mime: Some("application/pdf".to_owned()), + source_kind: ChatAttachmentSourceKind::Clipboard, + }], + }) + .await + .unwrap(); + + assert_eq!(output.attachments[0].filename, "clipboard.pdf"); + assert_eq!(output.attachments[0].mime, "application/pdf"); + assert_eq!( + output.attachments[0].source_kind, + ChatAttachmentSourceKind::Clipboard + ); + let calls = store.bytes_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, b"%PDF-1.7"); + assert_eq!(calls[0].1.filename, "clipboard.pdf"); + assert_eq!(calls[0].1.mime, "application/pdf"); +} + +#[tokio::test] +async fn import_chat_attachments_rejects_oversized_clipboard_bytes_before_store_call() { + let store = Arc::new(FakeChatAttachments::default()); + let usecase = ImportChatAttachments::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + + let err = usecase + .execute(ImportChatAttachmentsInput { + project: project(), + agent_id: AgentId::new_random(), + session_id: SessionId::new_random(), + attachments: vec![ImportChatAttachmentItem { + source: ImportChatAttachmentSource::Bytes { + filename: "too-big.pdf".to_owned(), + bytes: vec![0; (CHAT_ATTACHMENT_MAX_BYTES + 1) as usize], + }, + mime: Some("application/pdf".to_owned()), + source_kind: ChatAttachmentSourceKind::Clipboard, + }], + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("chat attachment exceeds")); + assert!(store.path_calls.lock().unwrap().is_empty()); + assert!(store.bytes_calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn import_chat_attachments_rejects_empty_clipboard_bytes_before_store_call() { + let store = Arc::new(FakeChatAttachments::default()); + let usecase = ImportChatAttachments::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + + let err = usecase + .execute(ImportChatAttachmentsInput { + project: project(), + agent_id: AgentId::new_random(), + session_id: SessionId::new_random(), + attachments: vec![ImportChatAttachmentItem { + source: ImportChatAttachmentSource::Bytes { + filename: "empty.txt".to_owned(), + bytes: Vec::new(), + }, + mime: Some("text/plain".to_owned()), + source_kind: ChatAttachmentSourceKind::Clipboard, + }], + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("chat attachment must not be empty")); + assert!(store.path_calls.lock().unwrap().is_empty()); + assert!(store.bytes_calls.lock().unwrap().is_empty()); +} + #[tokio::test] async fn import_chat_attachments_rejects_executable_sources_before_store_call() { let store = Arc::new(FakeChatAttachments::default()); diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 3061c125..5c272ff2 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1562,6 +1562,162 @@ describe("CustomAgentChatView", () => { ); }); + it("keeps pasted text insertion when clipboard files are attached", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "prefix suffix" } }); + composer.setSelectionRange(7, 13); + + const file = new File(["ignored"], "mixed.txt", { type: "text/plain" }); + Object.defineProperty(file, "arrayBuffer", { + value: vi.fn(async () => new Uint8Array([7, 8, 9]).buffer), + }); + fireEvent.paste(composer, { + clipboardData: { + items: [ + { + kind: "file", + type: "text/plain", + getAsFile: () => file, + }, + ], + getData: () => "inserted", + }, + }); + + await screen.findByText("Fichier joint: mixed.txt"); + await waitFor(() => expect(composer.value).toBe("prefix inserted")); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); + expect(agent.sendAgentChat).toHaveBeenCalledWith( + "chat-session-1", + "prefix inserted", + expect.any(Function), + { + attachments: [ + { + filename: "mixed.txt", + contentBase64: "BwgJ", + mime: "text/plain", + sourceKind: "clipboard", + }, + ], + }, + ); + expect(screen.getByText("prefix inserted")).toBeTruthy(); + expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy(); + }); + + it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const file = new File(["ignored"], "duplicate.pdf", { + type: "application/pdf", + lastModified: 123, + }); + Object.defineProperty(file, "arrayBuffer", { + value: vi.fn(async () => new Uint8Array([10, 11, 12]).buffer), + }); + const clipboardData = { + items: [ + { + kind: "file", + type: "application/pdf", + getAsFile: () => file, + }, + ], + files: [file], + getData: () => "", + }; + const composer = screen.getByLabelText(/message CLI custom/); + + fireEvent.paste(composer, { clipboardData }); + await screen.findByText("Fichier joint: duplicate.pdf"); + fireEvent.paste(composer, { clipboardData }); + + await waitFor(() => + expect(screen.getAllByText("Fichier joint: duplicate.pdf")).toHaveLength(1), + ); + }); + it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => { const agent = { launchAgentChat: vi.fn(() => new Promise(() => {})), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index ec51113e..a1b34188 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -57,6 +57,7 @@ interface AttachmentDraft { label: string; input: ChatAttachmentInput; previewUrl?: string; + dedupeKey?: string; } interface ProfileCommandDialogState { @@ -351,6 +352,7 @@ async function clipboardFileToAttachment(file: File): Promise { previewUrl: mime.startsWith("image/") ? `data:${mime};base64,${contentBase64}` : undefined, + dedupeKey: fileMetadataKey(file), input: { filename, contentBase64, @@ -360,6 +362,10 @@ async function clipboardFileToAttachment(file: File): Promise { }; } +function fileMetadataKey(file: File): string { + return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`; +} + function clipboardFiles(event: ClipboardEvent): File[] { const clipboardItems = event.clipboardData.items ? Array.from(event.clipboardData.items) @@ -373,7 +379,7 @@ function clipboardFiles(event: ClipboardEvent): File[] { .filter((file): file is File => Boolean(file)); const seen = new Set(); return [...fromItems, ...clipboardFiles].filter((file) => { - const key = `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`; + const key = fileMetadataKey(file); if (seen.has(key)) return false; seen.add(key); return true; @@ -958,7 +964,20 @@ export function CustomAgentChatView({ const nextAttachments = await Promise.all( files.map(clipboardFileToAttachment), ); - setAttachments((prev) => [...prev, ...nextAttachments]); + setAttachments((prev) => { + const known = new Set( + prev + .map((attachment) => attachment.dedupeKey) + .filter((key): key is string => Boolean(key)), + ); + const unique = nextAttachments.filter((attachment) => { + if (!attachment.dedupeKey) return true; + if (known.has(attachment.dedupeKey)) return false; + known.add(attachment.dedupeKey); + return true; + }); + return unique.length > 0 ? [...prev, ...unique] : prev; + }); if (pastedText) { setDraft((prev) => prev.slice(0, selectionStart) +