merge(chat): collage fichiers via Ctrl+V (#181, QA verte)

This commit is contained in:
2026-08-11 15:57:30 +02:00
7 changed files with 308 additions and 11 deletions

View File

@ -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.
- 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.

View File

@ -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à.

View File

@ -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",

View File

@ -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<String, AppError> {
let mime = raw
.map(str::trim)

View File

@ -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<dyn ChatAttachmentStore>,
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<dyn ChatAttachmentStore>,
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<dyn ChatAttachmentStore>,
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());

View File

@ -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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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<never>(() => {})),

View File

@ -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<AttachmentDraft> {
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<AttachmentDraft> {
};
}
function fileMetadataKey(file: File): string {
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
}
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
const clipboardItems = event.clipboardData.items
? Array.from(event.clipboardData.items)
@ -373,7 +379,7 @@ function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
.filter((file): file is File => Boolean(file));
const seen = new Set<string>();
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) +