feat(chat): colle les fichiers du presse-papiers via Ctrl+V dans le composer (#181)
This commit is contained in:
@ -110,6 +110,7 @@ impl ImportChatAttachments {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
ImportChatAttachmentSource::Bytes { bytes, .. } => {
|
ImportChatAttachmentSource::Bytes { bytes, .. } => {
|
||||||
|
validate_attachment_size(bytes.len() as u64)?;
|
||||||
self.store
|
self.store
|
||||||
.import_from_bytes(
|
.import_from_bytes(
|
||||||
&input.project.root,
|
&input.project.root,
|
||||||
@ -174,6 +175,20 @@ fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
|
|||||||
Ok(())
|
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> {
|
fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result<String, AppError> {
|
||||||
let mime = raw
|
let mime = raw
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||||
ImportChatAttachmentsInput,
|
ImportChatAttachmentsInput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[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");
|
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]
|
#[tokio::test]
|
||||||
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
||||||
let store = Arc::new(FakeChatAttachments::default());
|
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 () => {
|
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
||||||
|
|||||||
@ -57,6 +57,7 @@ interface AttachmentDraft {
|
|||||||
label: string;
|
label: string;
|
||||||
input: ChatAttachmentInput;
|
input: ChatAttachmentInput;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
dedupeKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileCommandDialogState {
|
interface ProfileCommandDialogState {
|
||||||
@ -351,6 +352,7 @@ async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
|||||||
previewUrl: mime.startsWith("image/")
|
previewUrl: mime.startsWith("image/")
|
||||||
? `data:${mime};base64,${contentBase64}`
|
? `data:${mime};base64,${contentBase64}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
dedupeKey: fileMetadataKey(file),
|
||||||
input: {
|
input: {
|
||||||
filename,
|
filename,
|
||||||
contentBase64,
|
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[] {
|
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||||
const clipboardItems = event.clipboardData.items
|
const clipboardItems = event.clipboardData.items
|
||||||
? Array.from(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));
|
.filter((file): file is File => Boolean(file));
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return [...fromItems, ...clipboardFiles].filter((file) => {
|
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;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@ -958,7 +964,20 @@ export function CustomAgentChatView({
|
|||||||
const nextAttachments = await Promise.all(
|
const nextAttachments = await Promise.all(
|
||||||
files.map(clipboardFileToAttachment),
|
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) {
|
if (pastedText) {
|
||||||
setDraft((prev) =>
|
setDraft((prev) =>
|
||||||
prev.slice(0, selectionStart) +
|
prev.slice(0, selectionStart) +
|
||||||
|
|||||||
Reference in New Issue
Block a user