feat(chat): colle les fichiers du presse-papiers via Ctrl+V dans le composer (#181)

This commit is contained in:
2026-08-11 15:57:30 +02:00
parent ce2640c9af
commit 419987d890
4 changed files with 296 additions and 3 deletions

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) +