merge(chat): collage fichiers via Ctrl+V (#181, QA verte)
This commit is contained in:
@ -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.
|
||||
@ -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à.
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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>(() => {})),
|
||||
|
||||
@ -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) +
|
||||
|
||||
Reference in New Issue
Block a user