diff --git a/.ideai/tickets/181/carnet.md b/.ideai/tickets/181/carnet.md index dfa4a5f2..5870dc68 100644 --- a/.ideai/tickets/181/carnet.md +++ b/.ideai/tickets/181/carnet.md @@ -1,10 +1,14 @@ --- issueRef: "#181" -version: 2 +version: 4 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786455261538 +updatedAt: 1786456609861 --- 2026-08-11 - Ticket ouvert suite au signalement utilisateur. - Cadrage UX: Ctrl+V doit joindre les fichiers sans casser le collage de texte; UI d'attachments existante à réutiliser; dédoublonnage par métadonnées. -- Cadrage architecture: chemin clipboard -> import d'attachments à compléter entre frontend, cas d'usage d'import et persistance attachments. \ No newline at end of file +- Cadrage architecture: chemin clipboard -> import d'attachments à compléter entre frontend, cas d'usage d'import et persistance attachments. +- Implémentation frontend dev reçue: collage texte seul inchangé; collage texte+fichiers joint les fichiers et insère le texte; dédoublonnage clipboard ajouté dans CustomAgentChatView. +- Implémentation backend dev reçue: fichiers clipboard non-image acceptés par ImportChatAttachments, validation taille/bytes vides dans l'app layer avant persistance; stockage durable existant conservé. +- QA 2026-08-11: VERT. Vérifications exécutées avec succès: npm test -- --run src/features/agents/CustomAgentChatView.test.tsx ; cargo test -p application chat_attachments. +- QA a ajouté un test ciblé manquant sur le rejet des bytes clipboard vides avant revalidation verte. \ No newline at end of file diff --git a/.ideai/tickets/181/issue.md b/.ideai/tickets/181/issue.md index 06ca0a0d..c2630726 100644 --- a/.ideai/tickets/181/issue.md +++ b/.ideai/tickets/181/issue.md @@ -2,7 +2,7 @@ id: "dfef095f-4cc0-4bff-8c21-3dfe33a93c44" number: 181 title: "Corriger le collage de fichiers dans le chat via Ctrl+V" -status: "inProgress" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1786454719235 -updatedAt: 1786455261538 -version: 2 +updatedAt: 1786456609861 +version: 4 --- Le collage Ctrl+V dans le chat doit permettre de joindre des fichiers de façon fiable. L'utilisateur soupçonne que le système actuel d'attachments doit être revu et indique qu'un chantier existait probablement déjà. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index b955dc8e..e0eed220 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -2427,7 +2427,7 @@ "issueRef": "#181", "path": "181", "title": "Corriger le collage de fichiers dans le chat via Ctrl+V", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -2435,7 +2435,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786455261538 + "updatedAt": 1786456609861 }, { "issueRef": "#182", 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) +